编程导航AI 零代码应用生成平台话题讨论

AI 零代码应用生成平台

78 参与
分享

快来分享你的内容吧~

点击登录,快来和大家讨论吧~
表情
图片
话题
打卡
综合
交流
文章
问答

完成"AI零代码应用生成平台"的应用模块扩展

# 扩展 - 应用版本对比 [扩展思路](https://www.codefather.cn/course/1948291549923344386/section/1950761936318550017#course-comment-view) 里提到了一些可以继续完善的功能,我参考 Diffchecker 的展示形式,实现了一个轻量级的版本对比能力:每次重新生成应用代码时,先把旧代码目录归档到历史版本目录,再覆盖写入最新代码。这样不需要立即修改数据库表结构,也能让用户对比“历史版本”和“当前版本”的文件内容差异。 详细代码可查看我的 [GitHub 仓库](https://github.com/iceblyte/ice-ai-code-mother) ,喜欢麻烦点个 star 吧 😄。 实现方案如下: - 后端保存代码前自动归档旧版本,目录结构为 `tmp/code_versions/{codeGenType}_{appId}/{versionKey}` - 后端提供版本列表接口和版本对比接口 - 前端在应用详情弹窗中增加 “版本对比” 按钮 - 版本对比弹窗支持选择旧版本、新版本和文件,并展示左右并排的代码差异 ## 1、保存代码时归档历史版本 修改 `CodeFileSaverTemplate` ,在覆盖当前代码目录前先归档旧版本,并清理当前目录,避免已经删除的文件继续残留: ```java public final File saveCode(T result, Long appId) { validateInput(result); String baseDirPath = buildUniqueDir(appId); archiveOldVersion(baseDirPath); FileUtil.clean(baseDirPath); saveFiles(result, baseDirPath); return new File(baseDirPath); } private void archiveOldVersion(String baseDirPath) { File baseDir = new File(baseDirPath); File[] files = baseDir.listFiles(); if (!baseDir.exists() || files == null || files.length == 0) { return; } String appDirName = baseDir.getName(); String versionKey = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmssSSS")); String versionDirPath = AppConstant.CODE_VERSION_ROOT_DIR + File.separator + appDirName + File.separator + versionKey; FileUtil.copyContent(baseDir, new File(versionDirPath), true); } ``` 在 `AppConstant` 中新增历史版本目录常量: ```java /** * 应用历史版本目录 */ String CODE_VERSION_ROOT_DIR = System.getProperty("user.dir") + "/tmp/code_versions"; ``` ## 2、后端版本对比接口 新增请求类 `AppVersionCompareRequest` ,用于指定要对比的应用、旧版本、新版本和文件路径: ```java @Data public class AppVersionCompareRequest implements Serializable { private Long appId; private String oldVersionKey; private String newVersionKey; private String filePath; private static final long serialVersionUID = 1L; } ``` 新增返回类 `AppVersionVO` 和 `AppVersionCompareVO` ,分别表示版本信息和文件对比结果: ```java @Data public class AppVersionVO implements Serializable { private String versionKey; private String versionName; private LocalDateTime createTime; private Boolean current; private static final long serialVersionUID = 1L; } ``` ```java @Data public class AppVersionCompareVO implements Serializable { private List<String> fileList; private String filePath; private String oldContent; private String newContent; private Integer additions; private Integer removals; private static final long serialVersionUID = 1L; } ``` 在 `AppController` 中新增两个接口: ```java @GetMapping("/version/list") public BaseResponse<List<AppVersionVO>> listAppVersions(@RequestParam Long appId, HttpServletRequest request) { ThrowUtils.throwIf(appId == null || appId <= 0, ErrorCode.PARAMS_ERROR, "应用 ID 不能为空"); User loginUser = userService.getLoginUser(request); return ResultUtils.success(appService.listAppVersions(appId, loginUser)); } @PostMapping("/version/compare") public BaseResponse<AppVersionCompareVO> compareAppVersion(@RequestBody AppVersionCompareRequest appVersionCompareRequest, HttpServletRequest request) { ThrowUtils.throwIf(appVersionCompareRequest == null, ErrorCode.PARAMS_ERROR); User loginUser = userService.getLoginUser(request); return ResultUtils.success(appService.compareAppVersion(appVersionCompareRequest, loginUser)); } ``` 核心对比逻辑在 `AppServiceImpl` 中,先校验本人或管理员权限,再读取两边文件内容,并统计新增和删除行数: ```java @Override public AppVersionCompareVO compareAppVersion(AppVersionCompareRequest request, User loginUser) { ThrowUtils.throwIf(request == null, ErrorCode.PARAMS_ERROR); Long appId = request.getAppId(); ThrowUtils.throwIf(appId == null || appId <= 0, ErrorCode.PARAMS_ERROR, "应用 ID 不能为空"); App app = getAndCheckVersionPermission(appId, loginUser); File oldVersionDir = resolveVersionDir(app, request.getOldVersionKey()); File newVersionDir = resolveVersionDir(app, request.getNewVersionKey()); ThrowUtils.throwIf(!oldVersionDir.exists() || !oldVersionDir.isDirectory(), ErrorCode.NOT_FOUND_ERROR, "旧版本不存在"); ThrowUtils.throwIf(!newVersionDir.exists() || !newVersionDir.isDirectory(), ErrorCode.NOT_FOUND_ERROR, "新版本不存在"); List<String> fileList = collectComparableFiles(oldVersionDir, newVersionDir); ThrowUtils.throwIf(fileList.isEmpty(), ErrorCode.NOT_FOUND_ERROR, "暂无可对比的代码文件"); String filePath = StrUtil.blankToDefault(request.getFilePath(), fileList.get(0)); ThrowUtils.throwIf(!fileList.contains(filePath), ErrorCode.PARAMS_ERROR, "文件路径无效"); String oldContent = readVersionFile(oldVersionDir, filePath); String newContent = readVersionFile(newVersionDir, filePath); int[] diffCounts = countLineDiff(oldContent, newContent); AppVersionCompareVO compareVO = new AppVersionCompareVO(); compareVO.setFileList(fileList); compareVO.setFilePath(filePath); compareVO.setOldContent(oldContent); compareVO.setNewContent(newContent); compareVO.setRemovals(diffCounts[0]); compareVO.setAdditions(diffCounts[1]); return compareVO; } ``` ## 3、前端版本对比弹窗 在 `appController.ts` 中手动补充接口调用: ```typescript export async function listAppVersions( params: API.listAppVersionsParams, options?: { [key: string]: any } ) { return request<API.BaseResponseListAppVersionVO>('/app/version/list', { method: 'GET', params: { ...params, }, ...(options || {}), }) } export async function compareAppVersion( body: API.AppVersionCompareRequest, options?: { [key: string]: any } ) { return request<API.BaseResponseAppVersionCompareVO>('/app/version/compare', { method: 'POST', headers: { 'Content-Type': 'application/json', }, data: body, ...(options || {}), }) } ``` 新增组件 `AppVersionCompareModal.vue` ,参考 Diffchecker 做成左右并排的代码对比面板: ```html <a-modal v-model:open="visible" title="版本对比" :footer="null" width="92vw"> <div class="compare-toolbar"> <a-select v-model:value="oldVersionKey" placeholder="选择旧版本" :options="versionOptions" @change="loadCompare" /> <SwapOutlined /> <a-select v-model:value="newVersionKey" placeholder="选择新版本" :options="versionOptions" @change="loadCompare" /> <a-select v-model:value="selectedFile" placeholder="选择文件" :options="fileOptions" @change="loadCompare" /> <span class="removal-count">{{ compareResult?.removals || 0 }} removals</span> <span class="addition-count">{{ compareResult?.additions || 0 }} additions</span> </div> <div class="diff-board"> <div class="diff-pane"> <div class="pane-header"> <span>{{ oldLines.length }} lines</span> <a-button type="link" size="small" @click="copyContent(oldContent)">Copy</a-button> </div> <div class="code-list"> <div v-for="(line, index) in oldRows" :key="index" class="code-line" :class="line.type"> <span class="line-no">{{ line.no || '' }}</span> <pre>{{ line.text }}</pre> </div> </div> </div> <div class="diff-pane"> <div class="pane-header"> <span>{{ newLines.length }} lines</span> <a-button type="link" size="small" @click="copyContent(newContent)">Copy</a-button> </div> <div class="code-list"> <div v-for="(line, index) in newRows" :key="index" class="code-line" :class="line.type"> <span class="line-no">{{ line.no || '' }}</span> <pre>{{ line.text }}</pre> </div> </div> </div> </div> </a-modal> ``` 这里没有引入额外依赖,而是在前端用最长公共子序列算法生成左右对齐的差异行: ```typescript const buildDiffRows = (oldLineList: string[], newLineList: string[]) => { const oldLength = oldLineList.length const newLength = newLineList.length const dp = Array.from({ length: oldLength + 1 }, () => Array(newLength + 1).fill(0)) for (let i = oldLength - 1; i >= 0; i--) { for (let j = newLength - 1; j >= 0; j--) { dp[i][j] = oldLineList[i] === newLineList[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]) } } // 根据 dp 结果组装 oldRows 和 newRows return { oldRows, newRows } } ``` 最后,在 `AppDetailModal.vue` 的操作栏中增加入口: ```vue <a-button @click="showVersionCompare = true"> <template #icon> <BranchesOutlined /> </template> 版本对比 </a-button> <AppVersionCompareModal v-model:open="showVersionCompare" :app-id="app?.id" /> ``` 效果上,用户重新生成应用后,点击 “应用详情” - “版本对比”,就能选择历史版本和当前版本,对指定文件进行左右并排对比,并看到新增行数和删除行数。 ## 4、测试验证 1)创建应用并生成一次代码,此时还没有历史版本,版本对比弹窗会提示暂无历史版本。 ![image.png](https://pic.code-nav.cn/post_picture/1863971691792248834/vxH7LBjaBZMiEna8.webp) 2)继续在对话框中发送修改需求并重新生成代码,后端会把第一次生成的代码归档为历史版本。 ![image.png](https://pic.code-nav.cn/post_picture/1863971691792248834/7CBGNfVjKzr59FoV.webp) 3)打开应用详情,点击 “版本对比”,选择历史版本和当前版本,确认能看到左右代码差异。 ![image.png](https://pic.code-nav.cn/post_picture/1863971691792248834/rySgckr2fp8CPW6Y.webp) 4)如果某个文件在新版本中被删除,左侧会显示删除内容,右侧为空行;如果新版本新增文件,则左侧为空行,右侧显示新增内容。 ![image.png](https://pic.code-nav.cn/post_picture/1863971691792248834/RNQWCg4PZVTNmJaC.webp)

完成”AI零代码应用生成平台“的用户模块扩展

[扩展思路](https://www.codefather.cn/course/1948291549923344386/section/1950029618990997505#course-comment-view) 里提到了一些可以继续完善的功能,这里给出一套完整实现,包含: - 已逻辑删除账号可重新注册 - 管理员编辑用户信息 - 用户个人中心 / 个人设置 - 基于路由 meta 的全局权限管理 下面分别讲解。 ### 1、已逻辑删除账号可重新注册 #### 问题分析 当前用户表中,`userAccount` 有唯一索引: ```sql UNIQUE KEY uk_userAccount (userAccount) ``` 虽然用户删除是逻辑删除,但数据库唯一索引仍然会占用原账号,所以会出现一种情况: 1. 某用户被逻辑删除 2. 前端再次用原账号注册 3. 业务层查询可能查不到有效用户 4. 但数据库插入时依然会因为唯一索引冲突而失败 所以,不能只在注册时“忽略逻辑删除数据”,还要想办法 **释放唯一键占用** 。 #### 实现方案 最稳妥的做法有 2 步: 1. 删除用户前,先把该用户原本的 `userAccount` 改写成一个带后缀的唯一值 2. 注册用户时,再兜底处理历史已删除但未释放账号的脏数据 #### 1)Mapper 层补充方法 在 `UserMapper` 中增加 2 个方法: ```java public interface UserMapper extends BaseMapper<User> { /** * 查询已逻辑删除的同账号用户 * * @param userAccount 账号 * @return 用户 */ User selectDeletedByUserAccount(@Param("userAccount") String userAccount); /** * 改写已删除用户账号,释放唯一键占用 * * @param id id * @param userAccount 新账号 * @return 影响行数 */ int updateUserAccountById(@Param("id") Long id, @Param("userAccount") String userAccount); } ``` 对应的 `UserMapper.xml`: ```xml <select id="selectDeletedByUserAccount" resultType="com.iceblyte.aicodemother.model.entity.User"> select id, userAccount, userPassword, userName, userAvatar, userProfile, userRole, editTime, createTime, updateTime, isDelete from user where userAccount = #{userAccount} and isDelete = 1 limit 1 </select> <update id="updateUserAccountById"> update user set userAccount = #{userAccount} where id = #{id} </update> ``` #### 2)注册时兜底处理历史脏数据 修改 `UserServiceImpl.userRegister`: ```java @Override public long userRegister(String userAccount, String userPassword, String checkPassword) { // 1. 校验 if (StrUtil.hasBlank(userAccount, userPassword, checkPassword)) { throw new BusinessException(ErrorCode.PARAMS_ERROR, "参数为空"); } if (userAccount.length() < 4) { throw new BusinessException(ErrorCode.PARAMS_ERROR, "用户账号过短"); } if (userPassword.length() < 8 || checkPassword.length() < 8) { throw new BusinessException(ErrorCode.PARAMS_ERROR, "用户密码过短"); } if (!userPassword.equals(checkPassword)) { throw new BusinessException(ErrorCode.PARAMS_ERROR, "两次输入的密码不一致"); } // 2. 检查是否重复 QueryWrapper queryWrapper = QueryWrapper.create().eq("userAccount", userAccount); long count = this.mapper.selectCountByQuery(queryWrapper); if (count > 0) { throw new BusinessException(ErrorCode.PARAMS_ERROR, "账号重复"); } // 兼容历史逻辑删除数据仍占用唯一键的情况 User deletedUser = this.mapper.selectDeletedByUserAccount(userAccount); if (deletedUser != null) { renameDeletedUserAccount(deletedUser); } // 3. 加密 String encryptPassword = getEncryptPassword(userPassword); // 4. 插入数据 User user = new User(); user.setUserAccount(userAccount); user.setUserPassword(encryptPassword); user.setUserName("无名"); user.setUserRole(UserRoleEnum.USER.getValue()); boolean saveResult = this.save(user); if (!saveResult) { throw new BusinessException(ErrorCode.SYSTEM_ERROR, "注册失败,数据库错误"); } return user.getId(); } ``` 补充一个私有方法: ```java private void renameDeletedUserAccount(User user) { String deletedUserAccount = String.format("%s_deleted_%s", user.getUserAccount(), IdUtil.getSnowflakeNextIdStr()); int updateRows = this.mapper.updateUserAccountById(user.getId(), deletedUserAccount); if (updateRows <= 0) { throw new BusinessException(ErrorCode.SYSTEM_ERROR, "释放已删除账号失败"); } } ``` #### 3)删除用户时释放账号 仅在注册时兜底还不够,最好在管理员删除用户时就释放账号占用。修改 `UserController.deleteUser`: ```java @PostMapping("/delete") @AuthCheck(mustRole = UserConstant.ADMIN_ROLE) public BaseResponse<Boolean> deleteUser(@RequestBody DeleteRequest deleteRequest) { if (deleteRequest == null || deleteRequest.getId() <= 0) { throw new BusinessException(ErrorCode.PARAMS_ERROR); } User user = userService.getById(deleteRequest.getId()); ThrowUtils.throwIf(user == null, ErrorCode.NOT_FOUND_ERROR); user.setUserAccount(String.format("%s_deleted_%d", user.getUserAccount(), user.getId())); boolean updateResult = userService.updateById(user); ThrowUtils.throwIf(!updateResult, ErrorCode.OPERATION_ERROR, "释放账号失败"); boolean b = userService.removeById(deleteRequest.getId()); return ResultUtils.success(b); } ``` 这样处理后: - 正常存在的账号仍然不能重复注册 - 已逻辑删除账号可以重新注册 - 历史脏数据也能被兼容处理 ### 2、个人中心 / 个人设置 需求:让当前登录用户可以修改自己的基础信息,比如昵称、头像、简介。 注意,这类接口和管理员更新任意用户资料的接口不同,必须限制为 **只能修改当前登录用户本人** 。 #### 1)新增请求类 在 `model.dto.user` 下新建 `UserUpdateMyRequest`: ```java @Data public class UserUpdateMyRequest implements Serializable { /** * 用户昵称 */ private String userName; /** * 用户头像 */ private String userAvatar; /** * 简介 */ private String userProfile; private static final long serialVersionUID = 1L; } ``` #### 2)Service 增加方法 在 `UserService` 中声明: ```java /** * 当前登录用户更新个人信息 * * @param userUpdateMyRequest 更新请求 * @param request 请求 * @return 是否成功 */ boolean updateMyUser(UserUpdateMyRequest userUpdateMyRequest, HttpServletRequest request); ``` 实现类中编写代码: ```java @Override public boolean updateMyUser(UserUpdateMyRequest userUpdateMyRequest, HttpServletRequest request) { if (userUpdateMyRequest == null) { throw new BusinessException(ErrorCode.PARAMS_ERROR); } User loginUser = this.getLoginUser(request); User updateUser = new User(); updateUser.setId(loginUser.getId()); updateUser.setUserName(userUpdateMyRequest.getUserName()); updateUser.setUserAvatar(userUpdateMyRequest.getUserAvatar()); updateUser.setUserProfile(userUpdateMyRequest.getUserProfile()); return this.updateById(updateUser); } ``` 这里的关键点是:**不从前端接收用户 id,而是直接从当前登录态里拿登录用户 id** ,避免越权修改其他用户。 #### 3)Controller 增加接口 在 `UserController` 中新增: ```java /** * 更新当前登录用户 */ @PostMapping("/update/my") public BaseResponse<Boolean> updateMyUser(@RequestBody UserUpdateMyRequest userUpdateMyRequest, HttpServletRequest request) { ThrowUtils.throwIf(userUpdateMyRequest == null, ErrorCode.PARAMS_ERROR); boolean result = userService.updateMyUser(userUpdateMyRequest, request); ThrowUtils.throwIf(!result, ErrorCode.OPERATION_ERROR); return ResultUtils.success(true); } ``` #### 4)前端增加个人中心页 新增页面 `src/pages/user/UserProfilePage.vue`: ```vue <template> <div id="userProfilePage"> <a-card title="个人中心"> <a-form layout="vertical" :model="formState" @finish="handleSubmit"> <a-form-item label="账号"> <a-input :value="loginUserStore.loginUser.userAccount" disabled /> </a-form-item> <a-form-item label="用户名"> <a-input v-model:value="formState.userName" placeholder="请输入用户名" /> </a-form-item> <a-form-item label="头像地址"> <a-input v-model:value="formState.userAvatar" placeholder="请输入头像地址" /> </a-form-item> <a-form-item label="个人简介"> <a-textarea v-model:value="formState.userProfile" placeholder="介绍一下自己" :auto-size="{ minRows: 4, maxRows: 6 }" /> </a-form-item> <a-form-item> <a-button type="primary" html-type="submit">保存资料</a-button> </a-form-item> </a-form> </a-card> </div> </template> ``` 页面逻辑: ```typescript const handleSubmit = async () => { const res = await updateMyUser({ ...formState, }) if (res.data.code === 0) { await loginUserStore.fetchLoginUser() message.success('保存成功') } else { message.error('保存失败,' + res.data.message) } } ``` 更新成功后重新调用 `fetchLoginUser()`,这样右上角头像、昵称等全局展示信息可以立即刷新。 ### 3、管理员编辑用户信息 除了删除用户,后台用户管理页面还可以增加“编辑用户”能力。 相比表格行内编辑,直接用弹窗表单实现会更稳一些,也更适合新手。 #### 1)表格操作列增加编辑按钮 在 `UserManagePage.vue` 的操作列中增加: ```vue <template v-else-if="column.key === 'action'"> <a-space> <a-button type="link" @click="openEditModal(record)">编辑</a-button> <a-button danger @click="doDelete(record.id)">删除</a-button> </a-space> </template> ``` #### 2)新增编辑弹窗 页面中增加 `a-modal`: ```vue <a-modal v-model:open="editModalOpen" title="编辑用户" ok-text="保存" cancel-text="取消" @ok="handleEditSubmit" > <a-form layout="vertical" :model="editForm"> <a-form-item label="用户名"> <a-input v-model:value="editForm.userName" placeholder="请输入用户名" /> </a-form-item> <a-form-item label="头像地址"> <a-input v-model:value="editForm.userAvatar" placeholder="请输入头像地址" /> </a-form-item> <a-form-item label="个人简介"> <a-textarea v-model:value="editForm.userProfile" placeholder="请输入简介" :auto-size="{ minRows: 3, maxRows: 5 }" /> </a-form-item> <a-form-item label="用户角色"> <a-select v-model:value="editForm.userRole" :options="userRoleOptions" /> </a-form-item> </a-form> </a-modal> ``` #### 3)打开弹窗时回填数据 ```typescript const openEditModal = (record: API.UserVO) => { editForm.id = record.id editForm.userName = record.userName editForm.userAvatar = record.userAvatar editForm.userProfile = record.userProfile editForm.userRole = record.userRole editModalOpen.value = true } ``` #### 4)保存编辑结果 ```typescript const handleEditSubmit = async () => { const res = await updateUser({ ...editForm, }) if (res.data.code === 0) { message.success('更新成功') editModalOpen.value = false await fetchData() } else { message.error('更新失败,' + res.data.message) } } ``` 这样管理员就可以在后台修改: - 用户昵称 - 用户头像 - 用户简介 - 用户角色 ### 4、基于路由 meta 的全局权限管理 前面我们已经提过一种简单权限控制方式:在权限文件中通过路由前缀(比如 `/admin`)判断是否允许访问。 但这种方式扩展性一般,页面越来越多时,会出现: - 权限规则分散 - 菜单和权限逻辑要维护两份 - 后续新增页面时容易漏改 更好的做法是:**把每个页面的权限要求直接写在路由配置里** 。 #### 1)新建 access 目录 在 `src/access/` 下新建 3 个文件: - `accessEnum.ts` - `checkAccess.ts` - `index.ts` 权限枚举: ```typescript const ACCESS_ENUM = { NOT_LOGIN: 'notLogin', USER: 'user', ADMIN: 'admin', } as const export default ACCESS_ENUM ``` 权限校验函数: ```typescript import ACCESS_ENUM from '@/access/accessEnum' const checkAccess = ( loginUser: API.LoginUserVO | undefined, needAccess: string = ACCESS_ENUM.NOT_LOGIN, ) => { const loginUserAccess = loginUser?.userRole ?? ACCESS_ENUM.NOT_LOGIN if (needAccess === ACCESS_ENUM.NOT_LOGIN) { return true } if (needAccess === ACCESS_ENUM.USER) { return loginUserAccess !== ACCESS_ENUM.NOT_LOGIN } if (needAccess === ACCESS_ENUM.ADMIN) { return loginUserAccess === ACCESS_ENUM.ADMIN } return true } export default checkAccess ``` #### 2)路由配置中声明页面权限 在 `router/index.ts` 中配置: ```typescript { path: '/user/profile', name: '个人中心', component: UserProfilePage, meta: { access: ACCESS_ENUM.USER, }, }, { path: '/admin/userManage', name: '用户管理', component: UserManagePage, meta: { access: ACCESS_ENUM.ADMIN, }, }, { path: '/noAuth', name: '无权限', component: NoAuthPage, meta: { hideInMenu: true, }, }, ``` 这里的 `meta.access` 表示页面所需权限,`meta.hideInMenu` 表示该页面不显示在导航菜单中。 #### 3)编写全局权限守卫 在 `src/access/index.ts` 中: ```typescript import router from '@/router' import ACCESS_ENUM from '@/access/accessEnum' import checkAccess from '@/access/checkAccess' import { useLoginUserStore } from '@/stores/loginUser' router.beforeEach(async (to) => { const loginUserStore = useLoginUserStore() let loginUser = loginUserStore.loginUser if (!loginUser.userRole) { await loginUserStore.fetchLoginUser() loginUser = loginUserStore.loginUser } const needAccess = (to.meta?.access as string) ?? ACCESS_ENUM.NOT_LOGIN if (needAccess === ACCESS_ENUM.NOT_LOGIN) { return true } if (!loginUser.userRole || loginUser.userRole === ACCESS_ENUM.NOT_LOGIN) { return `/user/login?redirect=${encodeURIComponent(to.fullPath)}` } if (!checkAccess(loginUser, needAccess)) { return '/noAuth' } return true }) ``` 这样处理后: 1. 公开页面直接放行 2. 需要登录的页面,未登录时跳转登录页 3. 已登录但权限不足时跳转无权限页 #### 4)配合登录态 Store 支持自动登录 为了让权限守卫生效,`loginUserStore` 默认要区分“未获取过登录态”和“明确未登录”。 修改默认值: ```typescript const loginUser = ref<API.LoginUserVO>({ userName: '未登录', userRole: ACCESS_ENUM.NOT_LOGIN, }) ``` 并在 `fetchLoginUser` 中处理未登录场景: ```typescript async function fetchLoginUser() { try { const res = await getLoginUser() if (res.data.code === 0 && res.data.data) { loginUser.value = res.data.data return } } catch (_error) { // ignore } loginUser.value = { userName: '未登录', userRole: ACCESS_ENUM.NOT_LOGIN, } } ``` #### 5)移除 App.vue 中重复请求登录态的代码 由于权限守卫已经负责全局自动登录,可以删除 `App.vue` 中原本的: ```typescript const loginUserStore = useLoginUserStore() loginUserStore.fetchLoginUser() ``` 避免重复请求。 ### 5、登录回跳与无权限页面 #### 1)登录回跳 当未登录用户访问受保护页面时,最好在登录成功后自动返回原页面。 全局请求拦截器可以这样改: ```typescript const redirect = `${window.location.pathname}${window.location.search}${window.location.hash}` window.location.href = `/user/login?redirect=${encodeURIComponent(redirect)}` ``` 登录页读取 `redirect` 参数: ```typescript const route = useRoute() const handleSubmit = async (values: any) => { const res = await userLogin(values) if (res.data.code === 0 && res.data.data) { await loginUserStore.fetchLoginUser() message.success('登录成功') const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/' router.push({ path: redirect, replace: true, }) } else { message.error('登录失败,' + res.data.message) } } ``` #### 2)新增无权限页面 新增 `NoAuthPage.vue`: ```vue <template> <div id="noAuthPage"> <a-result status="403" title="403" sub-title="你没有权限访问当前页面"> <template #extra> <a-button type="primary" @click="router.push('/')">返回首页</a-button> </template> </a-result> </div> </template> ``` 这样当用户虽然已登录,但访问了超出权限范围的页面时,会比直接跳登录页更合理。 ### 6、根据路由配置控制菜单显隐 如果页面权限已经配置在路由里,菜单显隐也应当复用同一份配置,而不是再单独维护一份管理员路由前缀判断。 修改 `GlobalHeader.vue`,先定义菜单项: ```typescript const originItems: MenuProps['items'] = [ { key: '/', label: '主页', title: '主页', }, { key: '/user/profile', label: '个人中心', title: '个人中心', }, { key: '/admin/userManage', label: '用户管理', title: '用户管理', }, ] ``` 根据路由配置过滤菜单: ```typescript const routeMap = computed(() => { const entries = appRouter.getRoutes().map((item) => [item.path, item] as const) return Object.fromEntries(entries) }) const menuItems = computed<MenuProps['items']>(() => { return originItems?.filter((menu) => { const menuKey = menu?.key as string if (!menuKey || !menuKey.startsWith('/')) { return true } const routeItem = routeMap.value[menuKey] if (!routeItem) { return true } if (routeItem.meta?.hideInMenu) { return false } const needAccess = (routeItem.meta?.access as string) ?? ACCESS_ENUM.NOT_LOGIN return checkAccess(loginUserStore.loginUser, needAccess) }) }) ``` 这样做的好处是: - 权限和菜单只维护一份配置 - 新增页面时只需要补路由 `meta` - 导航栏会自动根据当前用户权限显示可访问菜单 ### 7、右上角增加“个人中心” 用户下拉菜单中可以直接增加入口: ```vue <template #overlay> <a-menu> <a-menu-item @click="router.push('/user/profile')">个人中心</a-menu-item> <a-menu-item @click="doLogout"> <LogoutOutlined /> 退出登录 </a-menu-item> </a-menu> </template> ``` 这样用户编辑个人信息的入口就更加自然了。 ### 8、实现效果总结 完成以上改造后,用户模块除了最基础的注册、登录、注销、管理用户外,还额外拥有了这些能力: 1. 已被逻辑删除的账号支持重新注册 2. 管理员可以在后台编辑用户信息 3. 用户可以在个人中心修改自己的资料 4. 支持登录回跳 5. 支持无权限页面 6. 支持通过路由 `meta` 统一做全局权限管理 7. 支持根据权限自动隐藏无权限菜单

代码生成项目bug,生成前端页面有一个顶部的元素标题,通过对话一直无法解决,最后还是claude code解决的

![image.png](https://pic.code-nav.cn/post_picture/1624654021212004353/EkNfNKfgHTckJKmR.webp) 这个问题非常典型,暴露了 AI 代码生成的几个根本性局限。 解决方案 - 需要加上图片识别的模型 - 需要前端支持截图和框选 - 修改系统提示词但会较麻烦,无法预估问题出现可能性 ### 为什么系统对话修不好这个 Bug #### 1. AI 看不到渲染结果 代码生成的 System Prompt 告诉 AI 怎么写代码,但 AI **从来没有看过页面渲染效果**。它不知道导航栏里出现了一坨 JSON——它只知道:我写了 `t('products')`,这个调用是合法的 Vue/i18n 语法,不会编译报错。 ``` AI 的认知: t('products') → ✅ 语法正确,编译通过 真实的渲染: t('products') → 返回数组 → Vue dump 出整段 JSON ``` #### 2. 这是一个"静默 Bug",不是编译错误 AI 的工具都是基于文件操作的——`FileWriteTool`、`FileReadTool`、`FileModifyTool`——它们操作的是**源码文本**,不是运行时状态。这个 Bug 在每一层都不触发错误: | 层 | 检查结果 | | --------- | ----------------------------------- | | 语法检查 | ✅ `t('products')` 是合法的 JS 调用 | | 编译 | ✅ Vue SFC 编译通过 | | npm build | ✅ 构建成功 | | 渲染 | ❌ 导航栏显示 JSON 而不是文字 | AI 的工具链到第 3 步就停了,它根本不知道第 4 步出事了。 #### 3. AI 不理解自己的 i18n 键名冲突 > 根本问题在 System Prompt > > 这个 bug 的原因是 AI 在使用 i18n 键名时犯了同一个错误——把数据 key和导航 key起了同一个名字。如果经常出现这个问题,需要在 codegen 的 System Prompt 里加一条规则: > > 国际化键名规范: > - 导航/标签类用单个名词: "products", "about" > - 数据数组类用 Items 后缀: "productItems", "newsItems" > - 禁止数据数组和导航标签使用同名 key 当你告诉 AI "导航栏有长文字",AI 会把 `FileReadTool` 读取 `NavBar.vue`,然后看到: ```html <router-link v-for="link in links" :key="link.path" :to="link.path"> {{ t(link.key) }} </router-link> ``` 这行代码本身**没有任何问题**。`t(link.key)` 就是 `t('products')`,这是标准写法。AI 不会想到 `t('products')` 返回的是数组而不是字符串——它需要再去读 `i18n.js`,然后**在同一文件里发现 `products` 被定义了两次**,然后理解 JS 的对象字面量键名覆盖规则,然后意识到这是根本原因。 每一步的逻辑跳跃都成立,但把这些串起来是最难的——因为 AI 在每次工具调用之间是**无记忆的**,它不知道上一次读了 `NavBar.vue` 之后还需要去看 `i18n.js` 里的键名冲突。 #### 4. `FileModifyTool` 做的是文本替换,不是语义重构 即使 AI 发现了根因,它的修改工具也有限: ```java // FileModifyTool — 这是 AI 唯一能用的修改工具 @Tool("修改文件内容,用新内容替换指定的旧内容") public String modifyFile(String path, String oldContent, String newContent) ``` 这个工具只能做**精确的字符串替换**。但修复这个 Bug 需要: - 在 `i18n.js` 里把数组 key 从 `products` 改成 `productItems` - 在 `ProductGrid.vue` 里改 `t('products')` 为 `t('productItems')` - 在 `Products.vue` 里改 `v-for="p in t('products')"` 为 `t('productItems')` - 但又**不能改** `NavBar.vue` 和 `Footer.vue` 里的 `t('products')`(它们确实应该是字符串) 这相当于一次**跨 4 个文件、有选择性的语义重构**,AI 用文本替换工具做这件事,体验就是反复尝试反复失败。 ### 根本矛盾:Vibe Coding 的调试盲区 ``` 代码生成的 AI = 会写代码,但看不到渲染结果 对话修 Bug 的 AI = 能看到代码,但看不到运行时 用户 = 能看到 Bug 效果,但不一定清楚根因 ``` 三个人各掌握一段信息,谁都没有完整的"Bug → 根因 → 修复"链路。这和传统开发中"打开浏览器 DevTools → 看 Network 面板 → 定位到代码行 → 修改"的闭环完全不同。 ### 这类问题怎么解决 | 方案 | 可行性 | | --------------------------------- | ---------------------------------------------------------------------- | | **在 System Prompt 中加编码规范** | ✅ 最容易落地——"产品数据类键名加 Items 后缀,禁止和导航键名重名" | | **AI 生成后加质检 Rule** | ✅ 检查 i18n 文件中是否有同名 key 但类型不同的情况 | | **前端加防御渲染** | 每个 `{{ t(key) }}` 加类型检测——如果是数组就只显示长度而不 dump | | **给 AI 加浏览器工具** | 理论上可以让 AI 通过 Playwright 看到渲染结果然后修正,但工程复杂度很高 |

基于LangChain4j官方文档-AI零代码生成平台的RAG扩展与Skill集成

# AI零代码生成平台的RAG扩展与Skill集成 ## 一、前言 AI零代码生成平台初始版本只实现了"用户输入 → AI 输出代码"的简单流程。随着项目复杂度的提升,遇到了两个核心问题: 1. **知识复用问题**:每次代码生成都是"从零开始",AI 无法参考历史生成的优质代码。同样的设计模式、组件结构在不同项目中反复生成,质量参差不齐。 2. **规范约束问题**:代码生成的风格、规范完全依赖系统提示词,但提示词越长,AI 越容易忽略关键约束。需要一种更灵活的机制来按需注入规范。 针对这两个问题,我查阅了 LangChain4j 官方文档,在项目中实现了 **RAG(检索增强生成)** 和 **Skill(技能)** 两套扩展机制。 因为 Langchain4j 更新的很快,鱼皮做这个项目的时候好像使用的是1.1.0版本,现在最新版本是1.16.1。我是项目完成后更新到1.15.0后才去做的改造,更新后立马就是大片的红,所以自己扩展的时候请慎重,如果自己改造过程中出现什么问题,很可能就是版本问题,建议自己多去看看官方文档。接下来我在介绍时,也会贴出官方文档对应的网站地址。 下面我主要介绍我项目中 RAG 和 Skill 的实现,其他的改造欢迎查看我的 GitHub 仓库或者去线上地址体验~~~ 线上地址:http://www.icodeplay.site/ GitHub:https://github.com/20223309-zhou/ai-code-platform-backend --- ## 二、RAG 扩展 ### 2.1 实现目标 在本项目中,RAG 的核心目标不是让 AI"搜索资料",而是让 AI **在生成代码时参考已部署的优质代码块**,因为只有构建和部署成功的项目才能保证代码的正确性和可用性。当用户生成"电商网站"时,AI 能从已部署的模板库中检索到风格相似的导航栏、商品卡片等代码片段,作为生成参考,从而保持平台输出的一致性。 ### 2.2 向量嵌入模型选型 官方文档基于本地运行的嵌入模型介绍 [在这里](https://docs.langchain4j.dev/integrations/embedding-models/in-process) 本地嵌入模型完整列表 [可在此处找到](https://github.com/langchain4j/langchain4j/tree/main/embeddings) LangChain4j提供的本地嵌入模型简单对比: | 维度 | BgeSmallZhV15 | e5-small-v2 | AllMiniLmL6V2 | | ------------- | ----------------- | ---------------- | --------------------- | | 开发者 | 智源(BAAI) | 微软(intfloat) | sentence-transformers | | 向量维度 | 512 | 384 | 384 | | 语言侧重 | 中文 | 英文 | 英文(中文较弱) | | 参数量 | ~37M | ~38M | ~22M | | MTEB/基准表现 | C-MTEB 优异 | BEIR 49.0 | MTEB 56.3 | | 最佳场景 | 中文 RAG/语义搜索 | 英文高质量检索 | 原型/边缘部署 | 所以本项目选择了 **BAAI/bge-small-zh-v1.5(BgeSmallZhV15)** 作为嵌入模型,原因是: - **中文优化**:项目的中文 Prompt、代码注释、模板描述多,bge-small-zh 中英双语效果好 - **维度均衡**:512 维,精度高于 MiniLM 的 384 维,又远低于 text-embedding-3-large 的 3072 维,存储和检索效率高 - **本地部署(主要原因)**:LangChain4j 官方支持 ONNX 格式,模型约 30MB,零成本零延迟,离线可用 ### 2.3 向量数据库选型 一开始我选择的是Milvus,但是Milvus需要使用Docker进行集群部署,相对复杂,所以我去让AI为我进行选型; AI对比了四种方案: | 方案 | 结论 | |------|------| | **Qdrant** | ✅ 选用。Docker 单节点部署,LangChain4j 官方提供 `QdrantEmbeddingStore`,集成代码仅需几行 | | Milvus | ❌ 太重。适合大规模集群,小项目杀鸡用牛刀 | | PGvector | ❌ 需要绑定 PostgreSQL,项目未使用 PG | | Chroma | ❌ Python 生态为主,Java 集成不成熟 | 最终我选择了 **Qdrant**,余弦距离 + HNSW 索引,当前几千条代码向量检索毫秒级响应。 [官方文档的Qdrant介绍和示例在这里](https://docs.langchain4j.dev/integrations/embedding-stores/qdrant) ### 2.4 代码切分策略 代码切分不能像切散文一样按 token 数机械截断——那会从中间切开一个函数体,检索到的片段毫无意义。我实现了 **SplitExecutor 策略模式**,通过**标签的正则匹配**按代码的语义结构切分: ``` QdrantDocumentLoader 扫描 _deployed/ 目录 → LanguageTool 识别代码类型(Vue / HTML / 多文件) → SplitExecutor 路由到对应的 Splitter → VueSplitter:按 <template> / <script> / <style> 三大块拆分 → HtmlSplitter:按 <section> 顶层语义容器拆分 → MultiSplitter:保持文件完整性,每个文件为一个单元 → BgeSmallZhV15 嵌入 → Qdrant 存储 ``` **切分流程:** ``` loadDocuments(filePath) │ ▼ splitExecutor.chunk(项目路径, 文件内容) │ ├─ 从路径提取 codeGenType:"multi_file_1" → "multi" │ ├─ switch(codeGenType) │ │ │ ├─ "vue" → VueSplitter.chunk() │ ├─ "html" → HtmlSplitter.chunk() │ └─ "multi" → MultiSplitter.chunk() │ ▼ ┌─────────────────────────────────────────────────────┐ │ splitter.chunk() │ │ │ │ 文件内容 ≤ 200 字符 → 整个文件作为一个 TextSegment │ │ │ │ 文件内容 > 200 字符 → 按正则匹配语义标签拆分 │ │ ┌──────────┬────────────────────┐ │ │ │ Vue │ <template> │ │ │ │ │ <script> │ │ │ │ │ <style> │ │ │ ├──────────┼────────────────────┤ │ │ │ HTML │ <header> │ │ │ │ │ <section.hero> │ │ │ │ │ <footer> │ │ │ ├──────────┼────────────────────┤ │ │ │ Multi │ 整个文件作为一个块 │ │ │ └──────────┴────────────────────┘ │ │ │ │ 无匹配 → 整个文件作为一个 TextSegment │ │ │ └──────────────────────────────────────────────────────┘ │ ▼ ┌──────────────────────────────────────────────────────┐ │ supplementChunk(chunks) │ │ │ │ 对每个 TextSegment的text,调用 AI 模型: │ │ "用一句话描述以下代码的功能(不超过30字):" │ │ │ │ 将AI描述拼接为新的 TextSegment, | | text格式为"描述:代码内容",例如: │ │ "这是一个深蓝风格的导航栏组件:<nav>...</nav>" │ │ │ └──────────────────────────────────────────────────────┘ │ ▼ List<TextSegment> → embeddingModel.embedAll() → Qdrant存储 ``` TextSegment 的组成: ``` TextSegment { String text; // "导航栏组件:<nav>...</nav>"(描述:原始代码) Metadata metadata; // 元数据 } Metadata { "file_name" : "D:/tmp/code_output/vue_project_1/src/App.vue" // 文件完整路径 "tag_type" : "template" // template / script / style / header / full "project_type" : "vue" // vue / html / multi } ``` **存储流程:** 1、用户点击部署 → 后端调用`deployApp()` : ```java // ① 复制到部署目录(供访问) FileUtil.copyContent(sourceDir, new File(deployDirPath), true); // 在tmp目录下创建一个目录用来复制所有部署后的项目,作为写入Qdrant的语料库 // ② 复制到知识库目录(供检索) String filePath = RAG_LOAD_DIRECTORY_PATH + "/" + codeGenType + "_" + appId; if (!new File(filePath).exists()) { // 只处理首次部署 Thread.startVirtualThread(() -> { // 异步执行,不阻塞部署返回 FileUtil.copyContent(new File(sourceDirPath), new File(filePath), true); qdrantDocumentLoader.loadDocuments(filePath); // 核心:加载到 Qdrant }); } ``` 2、`loadDocuments(dirPath)` 内部流程 ``` loadDocuments(RAG_LOAD_DIRECTORY_PATH/multi_file_1) │ ├─ ① getExistingFilePaths() │ └─ 零向量搜索 Qdrant(maxResults=10000, minScore=0.0) │ └─ 提取所有已有文件的 metadata.file_name → Set<String> │ ├─ ② Files.walk(dirPath) 扫描目录 │ ├─ 排除: node_modules / dist / .git │ ├─ 只保留: .vue / .html / .js / .css │ └─ 得到待处理文件列表 │ └─ ③ 逐文件处理 ├─ 检查 metadata.file_name 是否在 Qdrant 中已存在 │ ├─ 已存在 → skip │ └─ 不存在 → │ ├─ splitExecutor.chunk(filePath, content) // 按语义结构切分 │ ├─ embeddingModel.embedAll(segments) // BGE 嵌入(512维) │ └─ embeddingStore.addAll(embeddings, segments) // 存入 Qdrant │ └─ 日志: 新增/跳过 统计 ``` 3、关键设计点 | 环节 | 说明 | | :--------------: | :----------------------------------------------------------- | | **去重机制** | `getExistingFilePaths()` 用零向量搜索查出 Qdrant 中所有已有文件的完整路径,新文件才处理。**已部署的项目再次部署不会重复索引** | | **异步执行** | `Thread.startVirtualThread()`,不阻塞部署接口返回 | | **目录隔离** | 部署目录 `deployKey/` 和知识库目录 `codeGenType_appId/` 分离。用户删除部署目录并不会删除知识库目录对应的项目,即使项目重部署,也不会重复导入到Qdrant中 | | **首次部署** | `if (!new File(filePath).exists())` 只检查目录是否存在,不检查内容是否更新。如需强制重新索引需要手动删目录 | | **增量而非全量** | 永远只添加新文件,不清理已删除的旧文件(旧文件的向量仍留在 Qdrant 中) | ### 2.5 RAG 执行链路 LangChain4j官方文档的的RAG实现 [在这里](https://docs.langchain4j.dev/tutorials/rag#retrieval-augmentor) 上面已经通过标签的语义切分将部署的项目全部存入了Qdrant,那拥有语料后LangChain4j是如何去实现RAG检索的呢? **完整链路:** ``` ┌─────────────────────────────────────────────────────────────┐ │ 用户启用 RAG 的完整检索链路 │ └─────────────────────────────────────────────────────────────┘ 前端 POST /chat/gen/code ?useRag=true │ ▼ ┌─────────────────────────────────┐ │ AppController.chatToGenCode() │ │ RagSwitchHolder.set(true) │ ← 使用ThreadLocal记录用户是否开启了RAG按钮 └──────────────────────────────────┘ │ ▼ ┌──────────────────────────────────┐ │ AiCodeGeneratorFacade │ │ 获取 AiService 实例 │ │ (已注入 retrievalAugmentor) │ └──────────────────────────────────┘ │ ▼ AiServices 处理用户消息 │ ▼ ┌──────────────────────────────────┐ │ DefaultRetrievalAugmentor │ │ 拦截用户消息,准备检索 │ └──────────────────────────────────┘ │ ▼ ┌──────────────────────────────────┐ │ ConditionalContentRetriever │ ← 装饰器 │ RagSwitchHolder.isEnabled() │ ← 从ThreadLocal获取boolean,判断是否需要开启RAG │ ├─ true → 继续检索 │ │ └─ false → 返回空列表,跳过 │ └──────────────────────────────────┘ │ true ▼ ┌──────────────────────────────────┐ │ EmbeddingStoreContentRetriever │ │ │ │ ① query.text() → embeddingModel │ │ BgeSmallZhV15.embed(userMsg) │ │ → 将用户消息转为 512 维向量 │ │ │ │ ② dynamicFilter(query) │ │ 如果 query 含 "vue" → 过滤 │ │ metadata.project_type = vue │ │ │ │ ③ embeddingStore.search() │ │ Qdrant 余弦相似度检索 │ │ maxResults=5, minScore=0.75 │ │ → 返回 Top-5 相似代码块 │ └──────────────────────────────────┘ │ ▼ ┌──────────────────────────────────┐ │ DefaultContentInjector │ │ 将检索结果注入用户消息: │ │ │ │ === 知识库参考 === │ │ 导航栏组件:<nav>...</nav> │ │ 商品卡片:<div>...</div> │ │ ... │ │ === 用户问题 === │ │ 帮我生成一个电商网站 │ │ │ │ → 返回增强后的 UserMessage │ └──────────────────────────────────┘ │ ▼ 发送给 LLM (DeepSeek / Qwen 等) │ ▼ 生成带参考的代码 │ ▼ ┌──────────────────────────────────┐ │ RagSwitchInterceptor │ │ afterCompletion() │ │ RagSwitchHolder.clear() │ ← 清理 ThreadLocal └──────────────────────────────────┘ ``` 在这个链路中,LangChain4j 提供了四个关键扩展点: | 组件 | 类名 | 角色 | | :----------------------------- | :------------------------------- | :---------------------------------------------------------- | | **ContentRetriever** | `EmbeddingStoreContentRetriever` | 将用户 query 向量化 → 搜索 Qdrant → 返回匹配的 TextSegment | | **ContentInjector** | `DefaultContentInjector` | 把检索到的代码块按模板拼接到用户消息中 | | **RetrievalAugmentor** | `DefaultRetrievalAugmentor` | 编排整个流程:调用 Retriever → 调用 Injector → 返回增强消息 | | **ContentRetriever(自定义)** | `ConditionalContentRetriever` | 装饰器模式,通过 ThreadLocal 动态开关检索 | EmbeddingStoreContentRetriever 做了什么? ``` // 配置 EmbeddingStoreContentRetriever.builder() .embeddingModel(embeddingModel) // BgeSmallZhV15,将 query 转向量 .embeddingStore(embeddingStore) // Qdrant,执行向量搜索 .dynamicFilter(query -> ...) // 可以根据TextSegment的元数据标签实现动态过滤 .maxResults(5) // 返回 Top-5 .minScore(0.75) // 相似度阈值 .build(); ``` 核心逻辑: ``` 用户消息"帮我生成一个电商网站" → BgeSmallZhV15.embed("帮我生成一个电商网站") → 得到 [0.123, -0.456, ..., 0.789] (512维向量) → Qdrant.search(向量, limit=5, score_threshold=0.75) → 返回 5 个最相似的代码 TextSegment → ContentInjector 按模板格式化后拼入用户消息 → LLM 收到:"参考这段代码的风格,帮我生成..." ``` --- ## 三、Skill 集成 ### 3.1 背景 随着视觉规范、组件设计规范、响应式断点规则不断增加,系统提示词越来越长。长提示词的问题:AI 在长文本中容易"迷失",尤其在需要专注特定任务时,无关的约束反而会干扰输出。 ### 3.2 Skill 设计 LangChain4j 提供了 `Skills.toolProvider()` 机制——每个 Skill 是一个包含 YAML 元信息的 Markdown 文件,注册为 AI 可调用的"技能工具"。AI 在生成过程中根据当前任务自主决定是否激活某个技能。 我的项目共实现了 9 个 Skill,放置在项目根目录的 `skills/` 下: | 技能 | 触发条件 | |------|---------| | form-validation-patterns | 页面包含表单 | | table-list-patterns | 需要展示列表/表格 | | api-call-pattern | 涉及 API 请求 | | responsive-breakpoints | 响应式布局 | | design-tokens | 定义颜色/间距/阴影 | | micro-interactions | 动画/过渡反馈 | | ui-reference | 用户上传了参考图片或 URL | | code-audit | 代码生成完成后质量检查 | | responsive-check | 验证多断点布局表现 | ### 3.3 集成方式 官方文档的 Skills 集成 [看这里](https://docs.langchain4j.dev/tutorials/skills#tool-mode-recommended) 根据官方的推荐,使用工具模式去集成 Skills,需要提前将 Skill 资源加载到内存中去: ![image.png](https://pic.code-nav.cn/post_picture/2034521651294535682/SxfdwmAiEAj01E2h.webp) 每个 `SKILL.md` 文件结构: ```yaml --- name: form-validation-patterns description: 智能表单校验:联动规则、动态表单项、实时反馈 trigger: 当前页面包含表单时 --- ## 表单校验实现规范 ... ``` 提前准备好需要的 Skills ,采用ClassPathSkillLoader去将Skills 文档加载到内存中去: ![image.png](https://pic.code-nav.cn/post_picture/2034521651294535682/1613cZBk20BUwf7H.webp) ![image.png](https://pic.code-nav.cn/post_picture/2034521651294535682/8jiGO983xgxpoGG5.webp) ```java @Configuration @Slf4j public class SkillsInitConfig { @Bean public Skills skills() { log.info("初始化 Skills ..."); return Skills.from(ClassPathSkillLoader.loadSkills("skills")); } } ``` ```java @Slf4j @Configuration public class AiCodeGeneratorServiceFactory { @Resource private Skills skills; ~~~ AiServices.builder(AiCodeGeneratorService.class) .toolProvider(skills.toolProvider()) // 一行代码注册所有 Skill .chatMemoryProvider(memoryId -> chatMemory) .tools(toolManager.getAllTools()) .build(); ``` AI 在生成代码时,如果判断当前任务触发了某个技能,会主动激活对应的 SKILL.md 获取完整规范,而不是让所有规范完整写在系统提示词里。这种**按需加载**的方式大幅降低了无效 token 消耗,同时规范的执行率更高。 --- ## 四、多模态支持 ### 4.1 图片与文本文件上传 对话生成支持上传**图片**和**文本文件**作为生成参考: ```java List<Content> contents = new ArrayList<>(); // 用户提示词 TextContent textContent = new TextContent(message); contents.add(textContent); // 图片 → ImageContent ImageContent imageContent = new ImageContent(imageUrl); contents.add(imageContent); // 文本文件 → TextContent TextContent textContent = new TextContent("=== 上传的文件内容 ===\n" + fileText); contents.add(textContent); // 构造多模态 UserMessage UserMessage userMessage = UserMessage.from(contents); ``` - **图片**:上传后被存储到腾讯云 COS,返回imageUrl转换成 ImageContent 送入 AI - **文本文件**:支持 `.md`、`.txt`、`.markdown`,读取内容后作为 TextContent 送入 AI - 文件大小限制 5MB,前端通过 MIME 类型和后缀双重校验 ### 4.2 网页检索工具(WebFetchTool) 除了上传本地文件,还提供了 **WebFetchTool**,AI 可以根据用户提供的 URL 访问目标网站,提取其配色方案、字体、布局结构作为设计参考。 结合 `ui-reference` Skill,流程如下: ``` 用户上传参考图片或提供参考网站 URL → 激活 ui-reference Skill → 分析参考素材的配色/字体/布局 → 调用 WebFetchTool 抓取目标网站样式 → 将分析结果融入代码生成 ``` ### 4.3 图片搜索与 Logo 生成 - **SearchImageTool**:通过 Bing 搜索可商用图片,用于填充网页中的配图 - **GenerateLogoSvg**:从 Iconify 图标库搜索 SVG 图标拼入 Logo 布局,搜不到时自动降级为首字母圆角方块 Logo --- ## 五、其他 ### 5.1 用户体验改进 #### 5.1.1 流式中断 — 从源头停止 AI 输出 官方文档的流式输出取消 [看这里](https://docs.langchain4j.dev/tutorials/response-streaming) 前端的停止按钮调用后端的取消接口: ``` 前端点击停止 → POST /app/cancel/{appId} → CancelGenerationManager.cancel(appId) → processTokenStream 检测到 isCancelled(appId) == true → context.streamingHandle().cancel() // LangChain4j 从源头截断 TokenStream → sink.complete() // 结束 SSE 流 ``` 关键点在于使用 `context.streamingHandle().cancel()` 而非 `sink.complete()`——前者真正通知 AI 停止生成,后者只是关闭了前端的连接,后端仍在空转。 #### 5.1.2 页面导航安全 当用户在生成过程中跳转到其他页面时: - **前端**:`onUnmounted` 中调用 `cancelAppGeneration` 接口,同时浏览器自动关闭 EventSource 连接 - **后端**:`CancelGenerationManager.remove(appId)` 清理状态 返回对话页时,组件重新挂载,检查是否有已有对话历史,有则加载、没有则重新发送初始 Prompt——不会自动重复生成。 #### 5.1.3 深度思考流式展示 官方文档支持推理思考过程的流式输出 [看这里](https://docs.langchain4j.dev/tutorials/response-streaming) 使用 DeepSeek 模型的流式推理能力,前端同时展示**推理过程(thinking)** 和 **生成结果(content)** 两个区域: ```javascript // thinking:展示推理过程 onPartialThinking → SSE 事件 → inner.type === 'thinking' → messages[index].thinking += inner.data // ai_response:展示生成结果 onPartialResponse → SSE 事件 → inner.type === 'ai_response' → messages[index].content += inner.data ``` 两个流独立渲染,互不干扰。用户可以看到 AI 的推理链的同时,逐步看到生成的代码内容。 #### 5.1.4 自动滚动与浮层按钮 ``` 用户位于消息底部 → 新内容自动滚动到底部(autoScrollIfNearBottom) 用户向上翻阅 → 停止自动滚动,右下角出现浮层▼按钮 点击按钮 → 滚动到底部 → 按钮消失 ``` 使用 `nextTick` 确保 DOM 更新完毕后再判断是否在底部,防止内容追加时滚动位置判断错误。 ### 5.2 业务优化 #### 5.2.1 流式输出的演进 三种模式最初都采用流式输出,但能力不同: - **HTML / 多文件模式**:最初使用 `Flux<String>` 流式输出代码文本,前端逐字渲染,但**不支持工具调用**。每次修改只能让 AI 全量重新生成代码,再通过 `CodeParserExecutor` 解析代码块 → `CodeFileSaverExecutor` 保存到文件。效率低,无法精准修改。 - **Vue 工程模式**:从一开始就使用 `TokenStream` + 工具调用。AI 通过 `FileWriteTool` 逐个写入文件,通过 `FileReadTool` / `FileModifyTool` 完成修改,天然支持精准操作。 我将 HTML 和多文件模式统一升级为 `TokenStream`,注册了 `FileReadTool`、`FileModifyTool`、`SearchImageTool`、`GenerateLogoSvg` 等工具,实现了与 Vue 模式一致的工具调用能力。 ## 总结 以上就是我基于官方文档,对AI零代码生成平台的扩展和改造。错误和不足的地方感谢指正。

易扣AI (Go + CloudWeGo) 企业级AI智能体项目教程 第5章:后端项目基于Eino对话记忆的对话历史模块搭建

## 一、方案设计 ### 业务需求描述 在集成Eino对话记忆之前,我们的代码生成智能体存在以下核心问题: ##### 问题1:对话无持久化 **现状:** ```go // 现有智能体的Generate方法 - 无状态设计 func (a *BaseAgent) Generate(ctx context.Context, userMessage string, chatTemplate prompt.ChatTemplate, adkAgent *adk.ChatModelAgent) (*schema.Message, error) { // 直接格式化Prompt,没有任何历史上下文 format, err := chatTemplate.Format(ctx, map[string]any{ "content": userMessage, // 只有当前消息,没有history! }) // ... } ``` **用户体验:** - 每次都要重新描述需求 - AI无法理解"它"、"这个"、"那个"等指代词 - 对话不连贯,体验极差 ##### 问题2:无法追溯对话历史 **现状:** - 智能体运行时内存中保存对话 - 服务重启后所有对话丢失 - 无法查询历史对话记录 - 无法回溯用户的完整需求变更过程 **业务影响:** - 用户无法回顾之前的对话内容 - 开发者无法调试AI生成的问题 - 无法进行数据分析(如用户常用功能统计) - 不符合合规性要求(需要保留操作日志) ##### 问题3:无多对话隔离 **现状:** 单个智能体处理多个应用对话 **安全风险:** - 应用A的用户可能看到应用B的对话 - 不同应用的对话历史混在一起 - 数据泄露风险高 - 无法按应用维度管理数据 ##### 问题4:无自动总结机制 **现状:** - 长对话导致Token消耗指数增长 - 20轮对话后,Prompt可能超过模型上下文窗口 - AI响应质量下降(注意力分散) - API调用成本急剧上升 **成本估算示例:** ``` 假设每条消息平均100 tokens: - 第1轮:200 tokens (用户+AI) - 第10轮:2000 tokens - 第20轮:4000 tokens - 第50轮:10000 tokens (超出许多模型的限制) API成本增长曲线:线性 → 指数级增长 ``` ### 对话历史分页方案选型:传统分页 vs 游标分页 在对话历史模块中,我们同时使用了两种分页方式:管理员接口使用**传统分页**,用户接口使用**游标分页**。为什么要这样设计?下面通过一个真实的案例来说明。 #### 一个真实的场景 假设你的应用"任务管理系统"已经和AI进行了 **50轮对话**,产生了 **100条消息记录**。用户打开对话历史页面,每页显示10条。 #### 传统分页的做法 传统分页的思路很简单:告诉数据库"我要第3页,每页10条"。 ``` 请求:GET /api/chat/history?appId=123&pageNum=3&pageSize=10 ``` 数据库执行的SQL: ```sql SELECT * FROM chat_history WHERE app_id = 123 ORDER BY create_time DESC LIMIT 10 OFFSET 20; ``` 翻译成白话就是:"跳过前20条,取接下来的10条"。 **看起来没问题?让我们看看会发生什么。** 用户正在浏览第3页时,另一条新的AI消息到达了,插入到了最新位置。此时数据变成了101条,最新的一条被推到了第1页的顶部。 用户点击"下一页"想看第4页: ```sql SELECT * FROM chat_history WHERE app_id = 123 ORDER BY create_time DESC LIMIT 10 OFFSET 30; ``` **问题出现了**——用户看到了第3页最后一条消息的重复!因为新插入的消息把所有记录往下挤了一位,OFFSET 30实际上跳过了一条本该看到的消息,而把第3页末尾的那条又展示了一次。 ``` 插入前: 插入后(新消息挤入第1页): 第1页:Msg100, Msg99... 第1页:Msg101, Msg100, Msg99... 第2页:Msg90, Msg89... 第2页:Msg91, Msg90, Msg89... 第3页:Msg80, Msg79... 第3页:Msg81, Msg80, Msg79... ← Msg81是新挤进来的 第4页:Msg70, Msg69... 第4页:Msg71, Msg70, Msg69... ← 用户看到的第4页,Msg71重复了! ``` 这就是传统分页的**数据漂移问题**——在有新数据插入时,页与页之间会出现重复或遗漏。 **另一个问题:性能** 当数据量很大时,OFFSET的代价很高: ```sql -- 查看第1000页,每页10条 SELECT * FROM chat_history ORDER BY create_time DESC LIMIT 10 OFFSET 9990; ``` 数据库并不是直接跳到第9990条,而是先扫描前9990条记录,然后丢弃它们,再返回接下来的10条。也就是说,**翻到越后面的页,查询越慢**。 ``` 第1页: 扫描 0 条 → 耗时 1ms 第10页: 扫描 90 条 → 耗时 3ms 第100页:扫描 990条 → 耗时 15ms 第1000页:扫描9990条 → 耗时 150ms ``` #### 游标分页的做法 游标分页的思路完全不同:不告诉数据库"我要第几页",而是告诉它"我要在某个位置之后的数据"。 ``` 第一次请求:GET /api/chat/history?appId=123&pageSize=10 (没有游标,从头开始取) 返回结果: Msg100 (createTime: 2025-01-10 10:30:00) Msg99 (createTime: 2025-01-10 10:28:00) ... Msg91 (createTime: 2025-01-10 10:10:00) 游标标记:lastCreateTime = 2025-01-10 10:10:00 ``` 用户向下滚动,加载更多: ``` 第二次请求:GET /api/chat/history?appId=123&pageSize=10&lastCreateTime=2025-01-10 10:10:00 (告诉数据库:从这条之后继续取) ``` 数据库执行的SQL: ```sql SELECT * FROM chat_history WHERE app_id = 123 AND create_time < '2025-01-10 10:10:00' ORDER BY create_time DESC LIMIT 10; ``` 翻译成白话就是:"给我比这个时间更早的10条"。 **关键区别来了**——即使此时有新消息插入,也不会影响结果。因为我们不是在说"跳过多少条",而是在说"从这个时间点之前取"。新消息的时间一定比游标更新,不会出现在结果中。 ``` 插入前和插入后的查询结果完全一致: 第二次请求返回: Msg90 (createTime: 2025-01-10 10:08:00) Msg89 (createTime: 2025-01-10 10:06:00) ... Msg81 (createTime: 2025-01-10 09:50:00) 不会出现重复,不会出现遗漏! ``` **性能方面**——游标分页利用了索引,无论翻到多深,查询速度都一样快: ```sql -- 第1次查询和第100次查询的执行计划完全相同 -- 都是利用 create_time 索引定位,然后向后扫描10条 -- 耗时始终在 1-2ms ``` ``` 第1次加载: 利用索引定位 → 耗时 1ms 第10次加载:利用索引定位 → 耗时 1ms 第100次加载:利用索引定位 → 耗时 1ms 第1000次加载:利用索引定位 → 耗时 1ms ``` #### 那为什么管理员接口还用传统分页? 游标分页虽然好,但也有它的局限: **局限1:无法跳页** 游标分页只能"下一页",不能直接跳到第5页。用户必须从第1页开始,一页一页往下翻。 ``` 传统分页:可以直接跳到第50页 → GET /api/admin/chat/history?pageNum=50 游标分页:必须从第1页开始,连续翻50次 → 不现实 ``` **局限2:无法显示总页数** 游标分页不知道总共有多少数据,所以无法显示"共100页"这样的信息。 ``` 传统分页:可以显示 "第3页/共100页" 游标分页:只能显示 "加载更多" 或 "没有更多了" ``` **局限3:排序字段必须唯一且递增** 游标分页依赖一个稳定的、单调递增的字段作为游标。如果排序字段有重复值,可能会漏数据。 ``` 用 createTime 做游标: 如果两条消息的 createTime 完全相同(精度到秒) → 可能会漏掉其中一条 → 解决方案:用 (createTime, id) 联合游标 ``` #### 我们的选择 根据两种分页的特点,我们这样分配: **用户查看对话历史 → 游标分页** 用户的场景是"向下滚动加载更多",不需要跳页,不需要总页数。对话是实时产生的,用游标分页可以避免数据漂移,保证体验流畅。 ``` 前端交互: 打开对话历史 → 加载最新10条 向下滚动 → 基于最后一条的时间加载更多 继续滚动 → 继续加载... 没有更多了 → 显示"已加载全部" ``` **管理员查看所有对话 → 传统分页** 管理员的场景是"后台管理",需要跳转到指定页码,需要看到总记录数,需要按多种条件筛选。数据变动对管理员来说影响不大。 ``` 前端交互: 打开管理后台 → 显示第1页,共50页 点击第5页 → 直接跳转 输入页码25 → 直接跳转 筛选条件变更 → 重新从第1页开始 ``` ### 数据库表设计 **对话历史表(chat_history)** **表结构:** | 字段名 | 类型 | 说明 | 约束 | | ----------- | ----------- | ------------------- | ----------------------------------- | | id | bigint | 主键ID | PRIMARY KEY, AUTO_INCREMENT | | message | text | 消息内容 | NOT NULL | | messageType | varchar(32) | 消息类型(user/ai) | NOT NULL | | appId | bigint | 应用ID | NOT NULL, INDEX | | userId | bigint | 用户ID | NOT NULL, INDEX | | turnNumber | int | 对话轮数 | NOT NULL, DEFAULT 1 | | createTime | datetime | 创建时间 | NOT NULL, DEFAULT CURRENT_TIMESTAMP | | updateTime | datetime | 更新时间 | NOT NULL, DEFAULT CURRENT_TIMESTAMP | | isDelete | tinyint | 是否删除 | NOT NULL, DEFAULT 0 | **索引设计:** - 主键索引:id - 联合索引:(appId, userId, turnNumber) - 普通索引:createTime **建表语句:** ```sql -- 对话历史表 create table if not exists chat_history ( id bigint auto_increment comment 'id' primary key, message text not null comment '消息内容', messageType varchar(32) not null comment '消息类型:user/ai', appId bigint not null comment '应用ID', userId bigint not null comment '用户ID', turnNumber int default 1 not null comment '对话轮数', createTime datetime default CURRENT_TIMESTAMP not null comment '创建时间', updateTime datetime default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '更新时间', isDelete tinyint default 0 not null comment '是否删除', INDEX idx_appId_userId (appId, userId), INDEX idx_turnNumber (turnNumber), INDEX idx_createTime (createTime) ) comment '对话历史' collate = utf8mb4_unicode_ci; ``` ## 二、对话历史接口开发 本节我将详细讲解对话历史模块的Service接口定义和Logic层实现,包括对话历史的增删改查、轮次管理、自动总结等核心功能。 ### Service 接口定义 **文件位置:** `internal/service/chat_history_service.go` **接口设计:** ```go type IChatHistoryService interface { AddChatMessage(ctx context.Context, appId int64, message string, messageType enum.ChatHistoryMessageTypeEnum, userId int64) error DeleteByAppId(ctx context.Context, appId int64) error ListAppChatHistoryByPage(ctx context.Context, appId int64, pageSize int32, lastCreateTime time.Time, loginUser *vo.UserVo) (*response.PageResponse[*model.ChatHistory], error) ListAllChatHistoryByPageForAdmin(ctx context.Context, pageNum int32, pageSize int32, queryRequest *api.YiKouChatHistoryQueryRequest) (*response.PageResponse[*model.ChatHistory], error) } ``` ### Logic 层实现 **文件位置:** `internal/logic/chat_history_logic.go` #### 服务初始化 ```go func NewChatHistoryService(db *gorm.DB) *ChatHistoryService { return &ChatHistoryService{ db: db, } } type ChatHistoryService struct { db *gorm.DB } ``` #### 分页查询应用对话历史(ListAppChatHistoryByPage) **功能说明:** 分页获取指定应用的对话历史记录,支持游标分页和时间过滤。 **完整代码:** ```go func (s *ChatHistoryService) ListAppChatHistoryByPage(ctx context.Context, appId int64, pageSize int32, lastCreateTime time.Time, loginUser *vo.UserVo) (*response.PageResponse[*model.ChatHistory], error) { // 1. 校验基本参数 if appId == 0 || appId < 0 || pageSize <= 0 || pageSize > 50 { return nil, errorutil.ParamsError } if loginUser == nil { return nil, errorutil.NotLoginError } // 2. 校验用户角色是否为管理员或者应用创建者 app, err := query.Use(s.db).App.Where(query.App.ID.Eq(appId)).First() if err != nil { return nil, err } if app.UserID != loginUser.ID && loginUser.UserRole != string(enum.AdminRole) { return nil, errorutil.NotAuthError } // 3. 构建查询条件 chatHistoryQuery := query.Use(s.db).ChatHistory. Where(query.ChatHistory.AppID.Eq(appId)). Where(query.ChatHistory.MessageType.Neq(string(enum.SummaryMessageType))) // 4. 处理时间过滤(游标分页) if !lastCreateTime.IsZero() { chatHistoryQuery = chatHistoryQuery.Where(query.ChatHistory.CreateTime.Lt(lastCreateTime)) } // 5. 查询总记录数 totalRow, err := chatHistoryQuery.Count() if err != nil { return nil, err } // 6. 计算总页数 totalPage := 0 if totalRow > 0 { totalPage = int((totalRow + int64(pageSize) - 1) / int64(pageSize)) } // 7. 分页查询应用的聊天记录 chatHistoryList, err := chatHistoryQuery. Order(query.ChatHistory.CreateTime.Desc()). Limit(int(pageSize)). Find() if err != nil { return nil, err } // 8. 构建并返回分页响应 return &response.PageResponse[*model.ChatHistory]{ Records: chatHistoryList, PageNum: 1, PageSize: int(pageSize), TotalPage: totalPage, TotalRow: int(totalRow), OptimizeCountQuery: true, }, nil } ``` #### 删除应用对话历史(DeleteByAppId) **功能说明:** 删除指定应用的所有对话记录,通常在删除应用时调用。 **完整代码:** ```go func (s *ChatHistoryService) DeleteByAppId(ctx context.Context, appId int64) error { // 1. 校验应用ID if appId == 0 || appId < 0 { return errorutil.ParamsError.WithMessage("应用ID不能为空") } // 2. 删除该应用的所有对话记录 _, err := query.Use(s.db).ChatHistory. Where(query.ChatHistory.AppID.Eq(appId)). Delete() if err != nil { return err } return nil } ``` #### 添加对话消息(AddChatMessage)⭐核心方法 **功能说明:** 添加一条对话消息到数据库,自动计算对话轮次,并在达到阈值时触发对话总结。 **完整代码:** ```go func (s *ChatHistoryService) AddChatMessage(ctx context.Context, appId int64, message string, messageType enum.ChatHistoryMessageTypeEnum, userId int64) error { // 1. 校验参数 if appId <= 0 || messageType == "" || userId <= 0 || message == "" { return errorutil.ParamsError } // 2. 获取上一条消息的轮次 lastMessage, err := query.Use(s.db).ChatHistory. Where(query.ChatHistory.AppID.Eq(appId)). Order(query.ChatHistory.CreateTime.Desc()). First() var turnNumber int32 if err != nil { turnNumber = 0 // 第一条消息,轮次为0 } else { turnNumber = lastMessage.TurnNumber } // 3. 如果当前是用户消息,开启新的一轮 if messageType == enum.UserMessageType { turnNumber += 1 } // 4. 生成雪花算法ID chatMessageId, err := snowflake.GenerateSnowFlakeId() if err != nil { return err } // 5. 创建对话记录 err = query.Use(s.db).ChatHistory.Create(&model.ChatHistory{ ID: chatMessageId, AppID: appId, Message: message, MessageType: string(messageType), UserID: userId, TurnNumber: turnNumber, }) if err != nil { return err } // 6. 当对话轮次达到20轮且为AI消息时,异步生成总结 if turnNumber >= 20 && messageType == enum.AIMessageType { go s.generateSummary(context.Background(), appId, userId) } return nil } ``` **步骤详解:** | 步骤 | 操作 | 说明 | | ---- | ---------- | ---------------------- | | 1 | 参数校验 | 验证所有必填参数 | | 2 | 查询轮次 | 获取上一条消息的轮次数 | | 3 | 计算新轮次 | 用户消息则轮次+1 | | 4 | 生成ID | 使用雪花算法生成唯一ID | | 5 | 保存记录 | 写入数据库 | | 6 | 触发总结 | 达到阈值后异步生成总结 | **对话轮次计算规则:** ``` 示例对话流程: 轮次1: - 用户消息(turnNumber=1) - AI响应(turnNumber=1) 轮次2: - 用户消息(turnNumber=2) - AI响应(turnNumber=2) 轮次3: - 用户消息(turnNumber=3) - AI响应(turnNumber=3) ... ``` #### 生成对话总结(generateSummary)⭐高级功能 **功能说明:** 当对话达到一定轮次时,异步生成对话总结,用于优化长对话的上下文管理。 **完整代码:** ```go // generateSummary 生成对话总结 func (s *ChatHistoryService) generateSummary(ctx context.Context, appId int64, userId int64) { // 1. 获取历史对话记录(按时间正序) historyList, err := query.Use(s.db).ChatHistory. Where(query.ChatHistory.AppID.Eq(appId)). Order(query.ChatHistory.CreateTime.Asc()). Find() if err != nil { logger.Errorf("获取历史对话失败: %v\n", err) return } // 2. 构建对话历史字符串 var chatHistoryBuilder strings.Builder for _, history := range historyList { if history.MessageType == string(enum.UserMessageType) { chatHistoryBuilder.WriteString(fmt.Sprintf("用户: %s\n", history.Message)) } else if history.MessageType == string(enum.AIMessageType) { chatHistoryBuilder.WriteString(fmt.Sprintf("AI: %s\n", history.Message)) } } err = s.AddChatMessage(ctx, appId, chatHistoryBuilder.String(), enum.SummaryMessageType, userId) if err != nil { logger.Errorf("对话总结保存失败: %v\n", err) } } ``` **对话格式化示例:** ``` 输入:数据库中的对话记录列表 输出格式化的对话字符串: 用户: 我需要一个任务管理系统 AI: 好的,我来帮你创建一个任务管理系统。这个系统将包括任务的增删改查功能... 用户: 需要支持优先级设置 AI: 明白,我会添加优先级字段,支持高、中、低三个级别... 用户: 还要有截止日期提醒 AI: 没问题,我会集成日期选择器和提醒功能... ``` 在后续我们会自定义一个对话总结智能体用于总结所有轮次的对话,实现真正意义上的节省token #### 管理员分页查询所有对话历史(ListAllChatHistoryByPageForAdmin) **功能说明:** 管理员专用的全量对话历史查询接口,支持多条件组合查询。 **完整代码:** ```go func (s *ChatHistoryService) ListAllChatHistoryByPageForAdmin(ctx context.Context, pageNum int32, pageSize int32, queryRequest *api.YiKouChatHistoryQueryRequest) (*response.PageResponse[*model.ChatHistory], error) { // 1. 校验基本参数 if pageNum <= 0 || pageSize <= 0 || pageSize > 50 { return nil, errorutil.ParamsError } if queryRequest == nil { return nil, errorutil.ParamsError } // 2. 构建基础查询 chatHistoryQuery := query.Use(s.db).ChatHistory. Where(query.ChatHistory.ID.IsNotNull()) // 3. 动态添加查询条件 if queryRequest.Id > 0 { chatHistoryQuery = chatHistoryQuery.Where(query.ChatHistory.ID.Eq(queryRequest.Id)) } if queryRequest.AppId > 0 { chatHistoryQuery = chatHistoryQuery.Where(query.ChatHistory.AppID.Eq(queryRequest.AppId)) } if queryRequest.UserId > 0 { chatHistoryQuery = chatHistoryQuery.Where(query.ChatHistory.UserID.Eq(queryRequest.UserId)) } if queryRequest.MessageType != "" { chatHistoryQuery = chatHistoryQuery.Where(query.ChatHistory.MessageType.Eq(queryRequest.MessageType)) } if queryRequest.Message != "" { chatHistoryQuery = chatHistoryQuery.Where( query.ChatHistory.Message.Like("%" + queryRequest.Message + "%") ) } if !queryRequest.LastCreateTime.IsZero() { chatHistoryQuery = chatHistoryQuery.Where( query.ChatHistory.CreateTime.Lt(queryRequest.LastCreateTime) ) } // 4. 查询总记录数 totalRow, err := chatHistoryQuery.Count() if err != nil { return nil, err } // 5. 计算总页数 totalPage := 0 if totalRow > 0 { totalPage = int((totalRow + int64(pageSize) - 1) / int64(pageSize)) } // 6. 计算偏移量 offset := int((pageNum - 1) * pageSize) // 7. 执行分页查询 chatHistoryList, err := chatHistoryQuery. Order(query.ChatHistory.CreateTime.Desc()). Limit(int(pageSize)). Offset(offset). Find() if err != nil { return nil, err } // 8. 构建并返回分页响应 return &response.PageResponse[*model.ChatHistory]{ Records: chatHistoryList, PageNum: int(pageNum), PageSize: int(pageSize), TotalPage: totalPage, TotalRow: int(totalRow), OptimizeCountQuery: true, }, nil } ``` ### Handler 层实现 **文件位置:** `internal/handler/chat_history_handler.go` #### Handler 结构体定义 ```go type ChatHistoryHandler struct { chatHistoryService service.IChatHistoryService userService service.IUserService } func NewChatHistoryHandler( chatHistoryService service.IChatHistoryService, userService service.IUserService, ) *ChatHistoryHandler { return &ChatHistoryHandler{ chatHistoryService: chatHistoryService, userService: userService, } } ``` #### 分页查询应用对话历史(ListAppChatHistory) **接口说明:** 用户查看指定应用的对话历史,使用游标分页。 **完整代码:** ```go func (h *ChatHistoryHandler) ListAppChatHistory(ctx context.Context, c *app.RequestContext) { // 1. 获取路径参数appId appIdStr := c.Param("appId") appId, err := strconv.ParseInt(appIdStr, 10, 64) if err != nil { c.JSON(consts.StatusOK, response.NewErrorResponse[any](errorutil.ParamsError.WithMessage("应用ID格式错误"))) return } // 2. 获取查询参数pageSize,默认值为10 pageSizeStr := c.Query("pageSize") pageSize := int32(10) // 默认值 if pageSizeStr != "" { if ps, err := strconv.Atoi(pageSizeStr); err == nil { pageSize = int32(ps) } } // 3. 获取查询参数lastCreateTime,可选 lastCreateTimeStr := c.Query("lastCreateTime") var lastCreateTime time.Time if lastCreateTimeStr != "" { if t, err := time.Parse(time.RFC3339, lastCreateTimeStr); err == nil { lastCreateTime = t } } // 4. 获取登录用户 loginUser, err := h.userService.GetLoginUserVo(ctx, c) if err != nil { c.JSON(consts.StatusOK, response.NewErrorResponse[any](err)) return } // 5. 调用服务层方法 result, err := h.chatHistoryService.ListAppChatHistoryByPage(ctx, appId, pageSize, lastCreateTime, &loginUser) if err != nil { c.JSON(consts.StatusOK, response.NewErrorResponse[any](err)) return } // 6. 返回成功响应 c.JSON(consts.StatusOK, response.NewSuccessResponse[*response.PageResponse[*model.ChatHistory]](result)) } ``` #### 管理员分页查询所有对话历史(ListAllChatHistoryByPageForAdmin) **接口说明:** 管理员查看所有应用的对话历史,支持多条件筛选,使用传统分页。 **请求参数(文件位置 `internal/api/chat_history.go`):** ```go type YiKouChatHistoryQueryRequest struct { Id int64 `json:"id"` // 对话ID AppId int64 `json:"appId"` // 应用ID UserId int64 `json:"userId"` // 用户ID MessageType string `json:"messageType"` // 消息类型 Message string `json:"message"` // 消息内容(模糊搜索) LastCreateTime time.Time `json:"lastCreateTime"` // 创建时间过滤 } type YiKouChatHistoryQueryResponse response.BaseResponse[response.PageResponse[*model.ChatHistory]] ``` **完整代码:** ```go func (h *ChatHistoryHandler) ListAllChatHistoryByPageForAdmin(ctx context.Context, c *app.RequestContext) { // 1. 绑定请求参数 req := &api.YiKouChatHistoryQueryRequest{} err := c.BindAndValidate(req) if err != nil { c.JSON(consts.StatusOK, response.NewErrorResponse[any](errorutil.ParamsError)) return } // 2. 获取分页参数 pageNum := int32(1) // 默认值 pageSize := int32(10) // 默认值 // 3. 调用服务层方法 result, err := h.chatHistoryService.ListAllChatHistoryByPageForAdmin(ctx, pageNum, pageSize, req) if err != nil { c.JSON(consts.StatusOK, response.NewErrorResponse[any](err)) return } // 4. 返回成功响应 c.JSON(consts.StatusOK, response.NewSuccessResponse[*response.PageResponse[*model.ChatHistory]](result)) } ``` #### 修改路由文件增加接口声明 文件位置 `internal/router/router.go` ```go // RegisterRoutes 注册路由 func RegisterRoutes(h *server.Hertz, url func(config *swagger.Config), db *gorm.DB, userHandler *handler.UserHandler, appHandler *handler.AppHandler, chatHistoryHandler *handler.ChatHistoryHandler) { // 注册全局中间件 // 处理跨域问题 h.Use(cors.New(cors.Config{ AllowAllOrigins: true, AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, AllowHeaders: []string{"Origin", "Content-Type", "Authorization"}, ExposeHeaders: []string{"Content-Length"}, AllowCredentials: false, MaxAge: 12 * time.Hour, })) // 全局异常处理 h.Use(recovery.Recovery(recovery.WithRecoveryHandler(CustomRecoveryHandler))) // 测试接口 h.GET("/ping", handler.Ping) // swaggo文档 h.GET("/swagger/*any", swagger.WrapHandler(swaggerFiles.Handler, url)) userRoute := h.Group("/user") { userRoute.POST("/register", userHandler.UserRegister) userRoute.POST("/login", userHandler.UserLogin) userRoute.GET("/get/vo", userHandler.GetUserVo) // 需要登录的接口 userRoute.GET("/get/login", middleware.AuthMiddleware(enum.UserRole, db), userHandler.GetLoginUser) userRoute.POST("/logout", middleware.AuthMiddleware(enum.UserRole, db), userHandler.Logout) // 需要管理员权限的接口 userRoute.POST("/add", middleware.AuthMiddleware(enum.AdminRole, db), userHandler.AddUser) userRoute.GET("/get", middleware.AuthMiddleware(enum.AdminRole, db), userHandler.GetUser) userRoute.POST("/delete", middleware.AuthMiddleware(enum.AdminRole, db), userHandler.DeleteUser) userRoute.POST("/update", middleware.AuthMiddleware(enum.AdminRole, db), userHandler.UpdateUser) userRoute.POST("/list/page/vo", middleware.AuthMiddleware(enum.AdminRole, db), userHandler.ListUserVoByPage) } appRoute := h.Group("/app") { appRoute.POST("/good/list/page/vo", appHandler.ListGoodApp) appRoute.GET("/get/vo", middleware.AuthMiddleware(enum.UserRole, db), appHandler.GetAppVo) // 需要登录的接口 appRoute.GET("/chat/gen/code", middleware.AuthMiddleware(enum.UserRole, db), appHandler.ChatToGenCode) appRoute.POST("/my/list/page/vo", middleware.AuthMiddleware(enum.UserRole, db), appHandler.ListMyApp) appRoute.POST("/add", middleware.AuthMiddleware(enum.UserRole, db), appHandler.AddApp) appRoute.POST("/update", middleware.AuthMiddleware(enum.UserRole, db), appHandler.UpdateApp) appRoute.POST("/delete", middleware.AuthMiddleware(enum.UserRole, db), appHandler.DeleteApp) // 需要管理员权限的接口 appRoute.POST("/admin/update", middleware.AuthMiddleware(enum.AdminRole, db), appHandler.AdminUpdateApp) appRoute.POST("/admin/delete", middleware.AuthMiddleware(enum.AdminRole, db), appHandler.AdminDeleteApp) appRoute.GET("/admin/get/vo", middleware.AuthMiddleware(enum.AdminRole, db), appHandler.AdminGetAppVo) appRoute.POST("/admin/list/page/vo", middleware.AuthMiddleware(enum.AdminRole, db), appHandler.AdminListApp) } // 聊天历史路由 chatHistoryRoute := h.Group("/chatHistory") { // 需要管理员权限的接口 chatHistoryRoute.POST("/admin/list/page/vo", middleware.AuthMiddleware(enum.AdminRole, db), chatHistoryHandler.ListAllChatHistoryByPageForAdmin) chatHistoryRoute.GET("/app/:appId", middleware.AuthMiddleware(enum.UserRole, db), chatHistoryHandler.ListAppChatHistory) } } ``` ### 应用模块集成对话历史服务 除了专门的对话历史接口外,应用模块也需要调用对话历史服务来保存用户和AI的对话记录。本节将详细描述 `app_handler.go` 和 `app_logic.go` 中如何集成对话历史服务。 #### Handler 层集成 **文件位置:** `internal/handler/app_handler.go` ##### AppHandler 结构体修改 在 `AppHandler` 中注入 `chatHistoryService`: ```go type AppHandler struct { appService service.IAppService userService service.IUserService chatHistoryService service.IChatHistoryService // ← 新增:对话历史服务 } func NewAppHandler( appService service.IAppService, userService service.IUserService, chatHistoryService service.IChatHistoryService, // ← 新增参数 ) *AppHandler { return &AppHandler{ appService: appService, userService: userService, chatHistoryService: chatHistoryService, } } ``` ##### ChatToGenCode 方法中的调用 **功能说明:** 在流式代码生成完成后,异步保存AI的响应消息到对话历史表。 **调用位置:** 流式响应结束后 **完整代码片段:** ```go func (a *AppHandler) ChatToGenCode(ctx context.Context, c *app.RequestContext) { // ... 前面的代码省略 var aiResponseBuilder strings.Builder for { // ... 流式读取代码省略 aiResponseBuilder.WriteString(chunk.Content) // ... 发送SSE事件省略 } // ← 关键调用:保存AI响应到对话历史 err = a.chatHistoryService.AddChatMessage(ctx, appId, aiResponseBuilder.String(), enum.AIMessageType, userVo.ID) if err != nil { logger.Errorf("保存对话历史失败: %v\n", err) } _ = w.WriteEvent(lastEventID, "done", []byte{1}) } ``` #### Service 层集成 **文件位置:** `internal/logic/app_logic.go` ##### AppService 结构体修改 在 `AppService` 中注入 `chatHistoryService`: ```go func NewAppService( aiCodeGenFacade *core.YiKouAiCodegenFacade, userService service.IUserService, chatHistoryService service.IChatHistoryService, // ← 新增:对话历史服务 db *gorm.DB, ) *AppService { return &AppService{ aiCodeGenFacade: aiCodeGenFacade, userService: userService, chatHistoryService: chatHistoryService, db: db, } } type AppService struct { aiCodeGenFacade *core.YiKouAiCodegenFacade userService service.IUserService chatHistoryService service.IChatHistoryService // ← 新增字段 db *gorm.DB } ``` ##### ChatToGenCode 方法中的调用 **功能说明:** 在调用代码生成服务前,先保存用户的对话消息到对话历史表。 **调用位置:** 参数校验通过后,调用代码生成服务前 **完整代码片段:** ```go func (s *AppService) ChatToGenCode(ctx context.Context, appId int64, message string, loginUser *vo.UserVo) (*schema.StreamReader[*schema.Message], error) { // 1. 校验参数 if message == "" { return nil, errorutil.ParamsError.WithMessage("消息不能为空") } if appId == 0 || appId < 0 { return nil, errorutil.ParamsError.WithMessage("应用ID不能为空") } // 2. 校验应用是否存在 app, err := query.Use(s.db).App.Where(query.App.ID.Eq(appId), query.App.IsDelete.Eq(0)).First() if err != nil { return nil, err } // 3. 校验用户是否有权限使用该应用 if app.UserID != loginUser.ID { return nil, errorutil.NotAuthError.WithMessage("无权使用该应用") } // 4. 获取代码生成类型 if enum.CodeGenTypeTextMap[enum.CodeGenTypeEnum(app.CodeGenType)] == "" { return nil, errorutil.ParamsError.WithMessage("应用代码生成类型不支持") } // ← 关键调用:保存用户消息到对话历史 err = s.chatHistoryService.AddChatMessage(ctx, appId, message, enum.UserMessageType, loginUser.ID) if err != nil { logger.Errorf("保存对话历史失败: %v\n", err) } // 6. 调用代码生成服务 return s.aiCodeGenFacade.GenCodeStreamAndSave(ctx, message, enum.CodeGenTypeEnum(app.CodeGenType), appId) } ``` ##### DeleteApp 方法中的调用 **功能说明:** 在删除应用时,级联删除该应用的所有对话历史记录。 **调用位置:** 应用逻辑删除成功后 **完整代码片段:** ```go func (s *AppService) DeleteApp(ctx context.Context, id int64, userId int64) (bool, error) { // 1. 查询应用 app, err := query.Use(s.db).App.Where(query.App.ID.Eq(id)).First() if err != nil { return false, err } // 2. 校验权限 if app.UserID != userId { return false, errorutil.ParamsError.WithMessage("无权删除该应用") } // 3. 逻辑删除应用 _, err = query.Use(s.db).App.Where(query.App.ID.Eq(id)).Update(query.App.IsDelete, 1) if err != nil { return false, err } // ← 关键调用:级联删除对话历史 err = s.chatHistoryService.DeleteByAppId(ctx, id) if err != nil { logger.Errorf("对话历史删除失败: %v\n", err) } return true, nil } ``` ## 三、集成Eino对话记忆 本节将详细讲解Eino框架的对话记忆机制在项目中的完整实现,包括基础设施层、存储层、Agent层和工厂层的架构设计与代码实现。 ### 对话记忆的保存方案设计 在深入实现细节之前,我们需要先理解一个核心设计决策:**为什么Eino对话记忆保存在Redis,而不是MySQL?** #### 一个关键的区别:对话历史 vs 对话记忆 很多人会混淆这两个概念,但它们有本质区别: **对话历史(Chat History)** - 保存在MySQL的 `chat_history` 表中 - 是**完整的、永久的**对话记录 - 用于**审计、统计、回溯** - 数据结构:包含 appId、userId、turnNumber、messageType 等完整字段 - 查询场景:管理员查看、用户翻阅历史、数据分析 **对话记忆(Conversation Memory)** - 保存在Redis的 `memory:{appId}` 键中 - 是**裁剪的、临时的**对话上下文 - 用于**AI实时推理** - 数据结构:只包含 role 和 content 的消息列表 - 查询场景:AI调用时加载上下文 用一个比喻来理解: ``` 对话历史 = 银行的交易流水账本 - 永久保存,不能修改 - 记录每一笔交易的完整信息 - 用于对账、审计、统计 - 存储在数据库 对话记忆 = 你的记账本摘要 - 只记录最近几笔交易 - 定期清理旧记录 - 用于快速查看当前财务状况 - 存储在便签纸上(Redis) ``` #### 为什么Eino对话记忆不用MySQL? 假设我们用MySQL存储Eino对话记忆,会发生什么? **场景:用户发送一条消息,AI生成响应** ``` [步骤1] 用户发送消息 "添加删除功能" ↓ [步骤2] 从MySQL加载对话记忆 执行SQL: SELECT * FROM chat_history WHERE app_id = 123 ORDER BY create_time DESC LIMIT 20; 耗时:5-10ms(需要解析SQL、优化查询、扫描索引、回表) ↓ [步骤3] 将查询结果转换为Eino的Message格式 遍历20条记录,构建 []*schema.Message 耗时:1-2ms ↓ [步骤4] 调用AI模型(带上下文) 耗时:2000-5000ms(网络请求) ↓ [步骤5] AI响应完成,保存新的对话记忆 执行SQL: INSERT INTO chat_history (...) VALUES (...); 耗时:3-5ms ↓ 总耗时:2010-5020ms ``` **如果用Redis存储对话记忆:** ``` [步骤1] 用户发送消息 "添加删除功能" ↓ [步骤2] 从Redis加载对话记忆 执行命令: GET memory:123 耗时:0.5-1ms(直接内存读取,无SQL解析) ↓ [步骤3] JSON反序列化为Message格式 耗时:0.5-1ms ↓ [步骤4] 调用AI模型(带上下文) 耗时:2000-5000ms(网络请求) ↓ [步骤5] AI响应完成,保存新的对话记忆 执行命令: SET memory:123 '...' EX 86400 耗时:0.5-1ms ↓ 总耗时:2002-5003ms ``` **性能对比:** | 操作 | MySQL | Redis | 差异 | | ---------- | ------ | ------- | ------------------ | | 加载记忆 | 5-10ms | 0.5-1ms | **快10倍** | | 保存记忆 | 3-5ms | 0.5-1ms | **快5倍** | | 总耗时影响 | +15ms | +3ms | **节省12ms** | 看起来差异不大?但考虑以下场景: **场景:高并发情况下,每秒100个用户同时对话** ``` MySQL方案: 每秒 100 次读取 + 100 次写入 = 200 次数据库操作 数据库连接池压力:高 慢查询风险:高(特别是OFFSET分页) 主从延迟风险:高(写入后立即读取可能读到旧数据) Redis方案: 每秒 100 次读取 + 100 次写入 = 200 次Redis操作 Redis单线程也能轻松处理(QPS可达10万+) 连接池压力:低 响应速度:稳定在1ms以内 ``` #### 那对话历史为什么还保存在MySQL? 既然Redis这么快,为什么不把对话历史也保存在Redis? **原因1:持久化要求** 对话历史是**审计数据**,必须永久保存。Redis虽然有RDB/AOF持久化,但: - RDB是定期快照,可能丢失最近的数据 - AOF虽然实时,但文件体积大,恢复慢 - Redis重启后数据可能丢失 MySQL的InnoDB引擎提供ACID事务保证,数据绝对不会丢失。 **原因2:复杂查询需求** 对话历史需要支持: - 分页查询(游标分页、传统分页) - 多条件筛选(按appId、userId、messageType) - 聚合统计(按应用统计对话数、按用户统计活跃度) - 关联查询(JOIN应用表、用户表) Redis只能做简单的Key-Value操作,无法支持这些复杂查询。 **原因3:数据分析需求** 运营需要分析: - 用户最常问的问题是什么? - 哪个应用的对话最多? - 用户活跃度趋势如何? 这些需要SQL聚合查询,Redis无法支持。 **原因4:合规性要求** 很多行业要求保留用户操作日志至少6个月,甚至永久保存。Redis的内存成本太高,不适合存储海量历史数据。 #### 存储方案总结 | 维度 | 对话历史(MySQL) | 对话记忆(Redis) | | ---------------- | ---------------------------- | ------------------------- | | **用途** | 审计、统计、回溯 | AI实时推理 | | **数据量** | 全量永久保存 | 最近20条,24小时TTL | | **字段** | 完整字段(appId、userId等) | 简化字段(role、content) | | **查询** | 复杂查询(分页、筛选、聚合) | 简单查询(GET、SET) | | **性能** | 5-10ms | 0.5-1ms | | **一致性** | 强一致(ACID) | 最终一致 | | **持久化** | 永久保存 | 可能丢失(可重建) | | **成本** | 磁盘存储,成本低 | 内存存储,成本高 | ### Redis初始化 我们先在控制台执行以下命令下载go-redis库,然后修改dal包下的 `init.go`文件 ```bash go get github.com/redis/go-redis/v9 v9.7.3 ``` **文件位置:** `internal/dal/init.go` #### 功能说明 负责项目核心基础设施的初始化,包括MySQL数据库连接和Redis客户端连接。而Redis是整个对话记忆系统的基础设施支撑,我们在原来的初始化内容增加一个Redis的provider方法用于注入。 #### 完整代码 ```go package dal import ( "fmt" "github.com/redis/go-redis/v9" "gorm.io/driver/mysql" "gorm.io/gorm" "gorm.io/gorm/logger" "yikou-ai-go-teach/config" "yikou-ai-go-teach/internal/dal/query" ) // InitDB 初始化数据库连接 func InitDB(config *config.Config) *gorm.DB { if config == nil { panic(fmt.Errorf("配置加载失败")) } dsn := config.Database.GetDSN() db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{ Logger: logger.Default.LogMode(logger.Info), }) if err != nil { panic(fmt.Errorf("数据库连接失败: %w", err)) } query.SetDefault(db) return db } // InitRedis 初始化Redis连接 func InitRedis(config *config.Config) *redis.Client { if config == nil { panic(fmt.Errorf("配置加载失败")) } redisClient := redis.NewClient(&redis.Options{ Addr: fmt.Sprintf("%s:%d", config.Redis.Host, config.Redis.Port), Password: config.Redis.Password, DB: config.Redis.DB, }) return redisClient } ``` ### RedisMemoryStore实现 **文件位置:** `internal/store/memory_store.go` #### 功能说明 由于Eino官方不支持将对话记忆抽象为组件,所有我根据Eino官网的方案自定义了一个MemoryStore的接口,我们只需要自定义一种对话记忆存储方式实现然后在基础agent引用就行了。这里我实现了基于Redis的对话记忆存储,作为MemoryStore接口的具体实现。负责对话消息的持久化存储和读取。 ![转存失败,建议直接上传图片文件](https://pic.code-nav.cn/post_picture/1730577019852419074/cRAGpZyjlqZujZqA.webp) #### 接口定义与实现 ```go package store import ( "context" "encoding/json" "fmt" "github.com/cloudwego/eino/schema" "github.com/redis/go-redis/v9" "time" ) // MemoryStore 对话记忆存储接口 type MemoryStore interface { GetMessages(ctx context.Context) ([]*schema.Message, error) AppendMessage(ctx context.Context, message *schema.Message) error } // RedisMemoryStore Redis实现的内存存储 type RedisMemoryStore struct { redisClient *redis.Client memoryId string maxMemoryMessages int ttl time.Duration } ``` #### 构造函数 ```go func NewRedisMemoryStore(redisClient *redis.Client, memoryId string, maxMemoryMessages int, ttl time.Duration) *RedisMemoryStore { return &RedisMemoryStore{ redisClient: redisClient, memoryId: memoryId, maxMemoryMessages: maxMemoryMessages, ttl: ttl, } } ``` #### 核心方法实现 ##### 获取消息列表(GetMessages) ```go func (r RedisMemoryStore) GetMessages(ctx context.Context) ([]*schema.Message, error) { key := fmt.Sprintf("memory:%s", r.memoryId) data, err := r.redisClient.Get(ctx, key).Bytes() if err != nil { return nil, err } return decodeMessagesFromJSON(data) } ``` ##### 追加消息(AppendMessage) ```go func (r RedisMemoryStore) AppendMessage(ctx context.Context, message *schema.Message) error { messages, err := r.GetMessages(ctx) if err != nil { if errors.Is(err, redis.Nil) { messages = []*schema.Message{} } else { return err } } messages = append(messages, message) messagesToJSON, err := encodeMessagesToJSON(messages) if err != nil { return err } key := fmt.Sprintf("memory:%s", r.memoryId) return r.redisClient.Set(ctx, key, messagesToJSON, r.ttl).Err() } ``` ##### 序列化辅助函数 ```go func encodeMessagesToJSON(msgs []*schema.Message) ([]byte, error) { return json.Marshal(msgs) } func decodeMessagesFromJSON(data []byte) ([]*schema.Message, error) { if len(data) == 0 { return nil, nil } var msgs []*schema.Message err := json.Unmarshal(data, &msgs) return msgs, err } ``` ### 修改基础Agent - Eino对话记忆核心实现 **文件位置:** `internal/ai/agent/base_agent.go` #### 修改结构体定义和构造函数 ```go type ChatModelWrapperAdaptor interface { GetChatModel() *openai.ChatModel GetModelName() string } type BaseAgent struct { model *openai.ChatModel modelName string memoryStore store.MemoryStore } func NewBaseAgent(chatModel ChatModelWrapperAdaptor, memoryStore store.MemoryStore) *BaseAgent { return &BaseAgent{ model: chatModel.GetChatModel(), modelName: chatModel.GetModelName(), memoryStore: memoryStore, } } ``` #### 修改流式生成方法(GenerateStream) ```go func (a *BaseAgent) GenerateStream(ctx context.Context, userMessage string, chatTemplate prompt.ChatTemplate, adkAgent *adk.ChatModelAgent) (*schema.StreamReader[*schema.Message], error) { // 1. 从Redis加载对话历史 messages, err := a.memoryStore.GetMessages(ctx) if err != nil { if errors.Is(err, redis.Nil) { messages = []*schema.Message{} } else { return nil, err } } // 2. 格式化Prompt(包含历史上下文) format, err := chatTemplate.Format(ctx, map[string]any{ "content": userMessage, "history": messages, }) if err != nil { return nil, err } err = a.memoryStore.AppendMessage(ctx, schema.UserMessage(userMessage)) if err != nil { return nil, err } // 3. 创建流式Runner runner := adk.NewRunner(ctx, adk.RunnerConfig{ Agent: adkAgent, EnableStreaming: true, }) iter := runner.Run(ctx, format) // 4. 创建管道用于流式传输 reader, writer := schema.Pipe[*schema.Message](2) // 5. 异步处理流数据 go func() { defer writer.Close() var fullContent string for { event, ok := iter.Next() if !ok { break } if event.Err != nil { writer.Send(nil, event.Err) return } if event.Output != nil && event.Output.MessageOutput != nil { stream := event.Output.MessageOutput.MessageStream if stream != nil { for { msg, err := stream.Recv() if err == io.EOF { break } if err != nil { writer.Send(nil, err) return } if msg != nil { fullContent += msg.Content writer.Send(msg, nil) } } } } } // 6. 将完整响应保存到Redis err := a.memoryStore.AppendMessage(ctx, schema.AssistantMessage(fullContent, nil)) if err != nil { logger.Errorf("保存对话记忆失败: %v", err) } }() return reader, nil } ``` ### 修改代码生成Agent **文件位置:** `internal/ai/agent/codegen_agent.go` ```go func NewCodeGenAgent(chatModel ChatModelWrapperAdaptor, codeGenType enum.CodeGenTypeEnum, memoryStore store.MemoryStore) *CodeGenAgent { baseAgent := NewBaseAgent(chatModel, memoryStore) return &CodeGenAgent{ BaseAgent: baseAgent, agentType: codeGenType, } } ``` ### 增加Agent工厂结构体 **文件位置:** `internal/ai/ai/agent/codegen_agent_factory.go` #### 为什么使用工厂模式? 在修改代码实现后,由于原有的智能体业务发生了较大的改动,于是在这里我引用工厂模式进行创建代码生成智能体。当然,在学习业务逻辑前,我们先理解为什么需要引入工厂模式。通过对比**没有工厂模式**和**有工厂模式**的代码,你会发现工厂模式解决了哪些核心问题。 ##### 问题场景:没有工厂模式时 假设我们直接在业务代码中创建 `CodeGenAgent`,会发生什么? **场景:用户请求生成HTML代码** ```go // 在 Handler 或 Service 中直接创建 Agent func (h *AppHandler) ChatToGenCode(c *gin.Context) { // ... 获取参数 redisStore := store.NewRedisMemoryStore( h.redisClient, // 需要传递Redis客户端 strconv.Itoa(int(appId)), // 需要手动转换类型 20, // 魔法数字:最大消息数 24*time.Hour, // 魔法数字:TTL ) agent := agent.NewCodeGenAgent( h.chatModel, // 需要传递ChatModel codeGenType, // 需要传递类型 redisStore, // 需要传递刚创建的Store ) // ... } ``` **这段代码有什么问题?** **问题1:重复代码** 每次需要Agent时,都要重复这段创建逻辑: ``` 创建RedisStore → 配置参数 → 创建Agent → 传递依赖 ``` 如果项目中有10个地方需要创建Agent,这段代码就要复制10次。 **问题2:违反开闭原则** 假设我们要修改RedisMemoryStore的配置: ```go // 从 20条消息 改为 30条消息 redisStore := store.NewRedisMemoryStore( h.redisClient, strconv.Itoa(int(appId)), 30, // ← 修改这里 24*time.Hour, ) ``` 我们需要找到所有创建Agent的地方,逐一修改。如果有10处,就要改10次。 ##### 解决方案:引入工厂模式 工厂模式的核心思想:**把对象的创建逻辑封装到一个专门的类中**。 ```go // 工厂类:专门负责创建 CodeGenAgent type CodeGenAgentFactory struct { chatModel *llm.ChatModelWrapper // 持有依赖 redisClient *redis.Client // 持有依赖 chatHistoryService service.IChatHistoryService // 持有依赖 } // 工厂方法:封装创建逻辑 func (c CodeGenAgentFactory) GetCodeGenAgent(appId int64, codeGenType enum.CodeGenTypeEnum) (*CodeGenAgent, error) { // 所有创建逻辑都在这里 redisStore := store.NewRedisMemoryStore(c.redisClient, strconv.Itoa(int(appId)), 20, 24*time.Hour) return NewCodeGenAgent(c.chatModel, codeGenType, redisStore), nil } ``` **业务代码变得简洁:** ```go // 在 Handler 或 Service 中 func (h *AppHandler) ChatToGenCode(c *gin.Context) { // ... 获取参数 // 一行代码创建Agent,所有细节都被封装 agent, err := h.agentFactory.GetCodeGenAgent(appId, codeGenType) if err != nil { // 错误处理 } // 业务逻辑清晰,不被创建逻辑干扰 stream, err := agent.GenerateHtmlCodeStream(ctx, userMessage) // ... } ``` #### 结构体定义 ```go type CodeGenAgentFactory struct { chatModel *llm.ChatModelWrapper redisClient *redis.Client chatHistoryService service.IChatHistoryService } ``` #### 构造函数 ```go func NewCodeGenAgentFactory(chatModel *llm.ChatModelWrapper, redisClient *redis.Client, chatHistoryService service.IChatHistoryService) *CodeGenAgentFactory { return &CodeGenAgentFactory{ chatModel: chatModel, redisClient: redisClient, chatHistoryService: chatHistoryService, } } ``` #### 创建Agent方法 ```go func (c CodeGenAgentFactory) GetCodeGenAgent(appId int64, codeGenType enum.CodeGenTypeEnum) (*CodeGenAgent, error) { redisStore := store.NewRedisMemoryStore(c.redisClient, strconv.Itoa(int(appId)), 20, 24*time.Hour) return NewCodeGenAgent(c.chatModel, codeGenType, redisStore), nil } ``` ### 修改门面结构体 **文件位置:** `internal/core/ai_codegen_facade.go` ```go package core import ( "context" "fmt" "io" "strings" "github.com/bytedance/gopkg/util/logger" "github.com/cloudwego/eino/schema" "yikou-ai-go-teach/internal/ai/agent" "yikou-ai-go-teach/internal/core/parser" "yikou-ai-go-teach/internal/core/saver" "yikou-ai-go-teach/pkg/enum" ) // YiKouAiCodegenFacade 代码生成门面 type YiKouAiCodegenFacade struct { codeGenFactory *agent.CodeGenAgentFactory // Agent工厂 codeParserExecutor *parser.CodeParserExecutor // 代码解析器 codeFileSaverExecutor *saver.CodeFileSaverExecutor // 代码保存器 } // NewYiKouAiCodegenFacade 构造函数 func NewYiKouAiCodegenFacade( codeGenFactory *agent.CodeGenAgentFactory, codeParserExecutor *parser.CodeParserExecutor, codeFileSaverExecutor *saver.CodeFileSaverExecutor, ) *YiKouAiCodegenFacade { return &YiKouAiCodegenFacade{ codeGenFactory: codeGenFactory, codeParserExecutor: codeParserExecutor, codeFileSaverExecutor: codeFileSaverExecutor, } } // GenCodeStreamAndSave 流式代码生成并保存(核心方法) func (y *YiKouAiCodegenFacade) GenCodeStreamAndSave( ctx context.Context, userMessage string, typeStr enum.CodeGenTypeEnum, appId int64, ) (*schema.StreamReader[*schema.Message], error) { // 创建Agent genAgent, err := y.codeGenFactory.GetCodeGenAgent(appId, typeStr) if err != nil { return nil, err } // 根据类型调用不同的生成方法 switch typeStr { case enum.HtmlCodeGen: streamResp, err := genAgent.GenerateHtmlCodeStream(ctx, userMessage) if err != nil { return nil, err } return y.processCodeStream(streamResp, typeStr, appId) case enum.MultiFileGen: streamResp, err := genAgent.GenerateMultiFileCodeStream(ctx, userMessage) if err != nil { return nil, err } return y.processCodeStream(streamResp, typeStr, appId) default: return nil, fmt.Errorf("不支持的代码生成类型: %s", typeStr) } } // processCodeStream 处理流式响应并异步保存 func (y *YiKouAiCodegenFacade) processCodeStream( respStream *schema.StreamReader[*schema.Message], typeStr enum.CodeGenTypeEnum, appId int64, ) (*schema.StreamReader[*schema.Message], error) { // 复制流 streams := respStream.Copy(2) processingStream := streams[0] returnStream := streams[1] // 异步处理 go func() { var builder strings.Builder defer processingStream.Close() // 读取流 for { chunk, err := processingStream.Recv() if err == io.EOF { break } if err != nil { return } builder.WriteString(chunk.Content) } // 解析和保存 parsedResp, err := y.codeParserExecutor.ExecuteParser(builder.String(), typeStr) if err != nil { return } dirPath, err := y.codeFileSaverExecutor.ExecuteSaver(parsedResp, typeStr, appId) if err != nil { return } logger.Info("代码已保存到目录: %s", dirPath) }() return returnStream, nil } ``` ### 修改依赖注入文件 **文件位置:** `wire/wire.go` ##### 修改点1:新增Redis客户端Provider ```go // 数据库依赖 var dbSet = wire.NewSet( dal.InitDB, // MySQL客户端 dal.InitRedis, // ← 新增:Redis客户端 ) ``` ##### 修改点2:新增ChatHistoryService注册并移除原来的agentService注入 ```go // Service依赖 var serviceSet = wire.NewSet( logic.NewAppService, wire.Bind(new(service.IAppService), new(*logic.AppService)), logic.NewUserService, wire.Bind(new(service.IUserService), new(*logic.UserService)), // ← 新增:ChatHistoryService logic.NewChatHistoryService, wire.Bind(new(service.IChatHistoryService), new(*logic.ChatHistoryService)), ) ``` ##### 修改点3:新增ChatHistoryHandler ```go // Handler依赖 var handlerSet = wire.NewSet( handler.NewUserHandler, handler.NewAppHandler, handler.NewChatHistoryHandler, ) ``` ##### 修改点4:新增CodeGenAgentFactory注册 ```go // 初始化函数 func InitializeApp() (*server.Hertz, error) { panic(wire.Build( initServer, configSet, dbSet, serviceSet, handlerSet, llmSet, parser.NewCodeParserExecutor, saver.NewCodeFileSaverExecutor, agent.NewCodeGenAgentFactory, // ← 新增:Agent工厂 core.NewYiKouAiCodegenFacade, )) } ``` ##### 修改点5:initServer方法 ```go // initServer 初始化 Web 服务器 func initServer(cfg *config.Config, userHandler *handler.UserHandler, appHandler *handler.AppHandler, db *gorm.DB, chatHistoryHandler *handler.ChatHistoryHandler) *server.Hertz { // 动态设置 Swagger 信息 docs.SwaggerInfo.Host = fmt.Sprintf("localhost:%d", cfg.Server.Port) docs.SwaggerInfo.BasePath = cfg.Server.ContextPath // 初始化swagger路径 swaggerPath := fmt.Sprintf("http://localhost:%d%s/swagger/doc.json", cfg.Server.Port, cfg.Server.ContextPath) url := swagger.URL(swaggerPath) // 创建 Hertz 服务器 h := server.Default( server.WithHostPorts(":"+strconv.Itoa(cfg.Server.Port)), server.WithBasePath(cfg.Server.ContextPath), ) // 注册路由 router.RegisterRoutes(h, url, db, userHandler, appHandler, chatHistoryHandler) return h } ``` **最后在wire目录下运行以下生成命令** ```bash wire gen wire/wire.go ``` 如果配置正确,会生成 `wire/wire_gen.go` 文件,无报错。 ### 测试对话记忆功能 直接启动前端服务器,然后随便生成一个应用 ![在这里插入图片描述](https://pic.code-nav.cn/post_picture/1969049470100254722/IUDgPr0mr0WmFMOv.webp) 然后查看Redis保存记忆对话是否成功 ![在这里插入图片描述](https://pic.code-nav.cn/post_picture/1969049470100254722/2yEOoeLbVsJYmSUc.webp) 可以得到对话记忆如下 ```json [ { "role": "user", "content": "做一个个人博客网站,包含文章列表和详情页面" }, { "role": "assistant", "content": "这是一个包含文章列表和详情功能的个人博客网站。它使用原生 JavaScript 模拟了单页面应用(SPA)的视图切换体验。\n\n```html\n\u003c!DOCTYPE html\u003e\n\u003chtml lang=\"zh-CN\"\u003e\n\u003chead\u003e\n \u003cmeta charset=\"UTF-8\"\u003e\n \u003cmeta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"\u003e\n \u003ctitle\u003e我的极简博客\u003c/title\u003e\n \u003cstyle\u003e\n /* --- 全局重置与基础样式 --- */\n :root {\n --primary-color: #2c3e50;\n --accent-color: #3498db;\n --bg-color: #f9f9f9;\n --card-bg: #ffffff;\n --text-color: #333;\n --text-light: #666;\n --border-radius: 8px;\n --shadow: 0 4px 6px rgba(0,0,0,0.05);\n --transition: all 0.3s ease;\n }\n\n * {\n box-sizing: border-box;\n margin: 0;\n padding: 0;\n }\n\n body {\n font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, sans-serif;\n line-height: 1.6;\n color: var(--text-color);\n background-color: var(--bg-color);\n display: flex;\n flex-direction: column;\n min-height: 100vh;\n }\n\n a {\n text-decoration: none;\n color: inherit;\n }\n\n ul {\n list-style: none;\n }\n\n img {\n max-width: 100%;\n display: block;\n }\n\n /* --- 头部导航 --- */\n header {\n background-color: var(--card-bg);\n box-shadow: var(--shadow);\n position: sticky;\n top: 0;\n z-index: 100;\n }\n\n .nav-container {\n max-width: 1200px;\n margin: 0 auto;\n padding: 1rem 2rem;\n display: flex;\n justify-content: space-between;\n align-items: center;\n }\n\n .logo {\n font-size: 1.5rem;\n font-weight: 700;\n color: var(--primary-color);\n }\n\n .nav-links a {\n margin-left: 20px;\n font-weight: 500;\n color: var(--text-light);\n transition: var(--transition);\n }\n\n .nav-links a:hover {\n color: var(--accent-color);\n }\n\n /* --- 主要内容区域 --- */\n main {\n flex: 1;\n max-width: 1200px;\n margin: 2rem auto;\n padding: 0 2rem;\n width: 100%;\n }\n\n /* --- 文章列表视图 --- */\n .view-section {\n display: none; /* 默认隐藏所有视图 */\n animation: fadeIn 0.5s ease;\n }\n\n .view-section.active {\n display: block; /* 激活时显示 */\n }\n\n .page-title {\n margin-bottom: 2rem;\n font-size: 2rem;\n color: var(--primary-color);\n border-bottom: 2px solid var(--accent-color);\n display: inline-block;\n padding-bottom: 0.5rem;\n }\n\n .post-grid {\n display: grid;\n grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));\n gap: 2rem;\n }\n\n .post-card {\n background: var(--card-bg);\n border-radius: var(--border-radius);\n overflow: hidden;\n box-shadow: var(--shadow);\n transition: var(--transition);\n cursor: pointer;\n display: flex;\n flex-direction: column;\n }\n\n .post-card:hover {\n transform: translateY(-5px);\n box-shadow: 0 10px 15px rgba(0,0,0,0.1);\n }\n\n .post-card-image {\n height: 200px;\n width: 100%;\n object-fit: cover;\n }\n\n .post-card-content {\n padding: 1.5rem;\n flex: 1;\n display: flex;\n flex-direction: column;\n }\n\n .post-meta {\n font-size: 0.85rem;\n color: var(--text-light);\n margin-bottom: 0.5rem;\n }\n\n .post-title {\n font-size: 1.25rem;\n margin-bottom: 0.75rem;\n color: var(--primary-color);\n }\n\n .post-excerpt {\n color: var(--text-light);\n font-size: 0.95rem;\n margin-bottom: 1.5rem;\n flex: 1;\n }\n\n .read-more {\n color: var(--accent-color);\n font-weight: 600;\n font-size: 0.9rem;\n align-self: flex-start;\n }\n\n /* --- 文章详情视图 --- */\n .back-btn {\n display: inline-flex;\n align-items: center;\n margin-bottom: 1.5rem;\n color: var(--text-light);\n cursor: pointer;\n font-weight: 500;\n transition: var(--transition);\n }\n\n .back-btn:hover {\n color: var(--accent-color);\n }\n\n .back-btn::before {\n content: '←';\n margin-right: 5px;\n font-size: 1.2rem;\n }\n\n .article-header {\n margin-bottom: 2rem;\n text-align: center;\n }\n\n .article-title {\n font-size: 2.5rem;\n margin-bottom: 1rem;\n color: var(--primary-color);\n }\n\n .article-cover {\n width: 100%;\n height: 400px;\n object-fit: cover;\n border-radius: var(--border-radius);\n margin-bottom: 2rem;\n box-shadow: var(--shadow);\n }\n\n .article-content {\n background: var(--card-bg);\n padding: 3rem;\n border-radius: var(--border-radius);\n box-shadow: var(--shadow);\n font-size: 1.1rem;\n line-height: 1.8;\n }\n\n .article-content p {\n margin-bottom: 1.5rem;\n }\n\n .article-content h2 {\n margin-top: 2rem;\n margin-bottom: 1rem;\n color: var(--primary-color);\n }\n\n /* --- 底部 --- */\n footer {\n background-color: var(--primary-color);\n color: #fff;\n text-align: center;\n padding: 2rem;\n margin-top: auto;\n }\n\n /* --- 动画 --- */\n @keyframes fadeIn {\n from { opacity: 0; transform: translateY(10px); }\n to { opacity: 1; transform: translateY(0); }\n }\n\n /* --- 响应式调整 --- */\n @media (max-width: 768px) {\n .nav-container {\n flex-direction: column;\n gap: 1rem;\n }\n \n .nav-links a {\n margin: 0 10px;\n }\n\n .article-title {\n font-size: 1.8rem;\n }\n\n .article-cover {\n height: 250px;\n }\n\n .article-content {\n padding: 1.5rem;\n }\n }\n \u003c/style\u003e\n\u003c/head\u003e\n\u003cbody\u003e\n\n \u003c!-- 导航栏 --\u003e\n \u003cheader\u003e\n \u003cdiv class=\"nav-container\"\u003e\n \u003ca href=\"#\" class=\"logo\" onclick=\"app.showHome()\"\u003eMyBlog\u003c/a\u003e\n \u003cnav class=\"nav-links\"\u003e\n \u003ca href=\"#\" onclick=\"app.showHome()\"\u003e首页\u003c/a\u003e\n \u003ca href=\"#\"\u003e关于我\u003c/a\u003e\n \u003ca href=\"#\"\u003e联系\u003c/a\u003e\n \u003c/nav\u003e\n \u003c/div\u003e\n \u003c/header\u003e\n\n \u003c!-- 主内容区 --\u003e\n \u003cmain\u003e\n \u003c!-- 1. 文章列表视图 --\u003e\n \u003csection id=\"list-view\" class=\"view-section active\"\u003e\n \u003ch1 class=\"page-title\"\u003e最新文章\u003c/h1\u003e\n \u003cdiv class=\"post-grid\" id=\"post-container\"\u003e\n \u003c!-- 文章卡片将通过 JS 插入这里 --\u003e\n \u003c/div\u003e\n \u003c/section\u003e\n\n \u003c!-- 2. 文章详情视图 --\u003e\n \u003csection id=\"detail-view\" class=\"view-section\"\u003e\n \u003cdiv class=\"back-btn\" onclick=\"app.showHome()\"\u003e返回列表\u003c/div\u003e\n \n \u003carticle id=\"article-container\"\u003e\n \u003c!-- 文章详情将通过 JS 插入这里 --\u003e\n \u003c/article\u003e\n \u003c/section\u003e\n \u003c/main\u003e\n\n \u003c!-- 页脚 --\u003e\n \u003cfooter\u003e\n \u003cp\u003e\u0026copy; 2023 My Personal Blog. All rights reserved.\u003c/p\u003e\n \u003cp style=\"font-size: 0.8rem; margin-top: 0.5rem; opacity: 0.7;\"\u003eDesigned with Native HTML/CSS/JS\u003c/p\u003e\n \u003c/footer\u003e\n\n \u003cscript\u003e\n // --- 模拟数据 ---\n const postsData = [\n {\n id: 1,\n title: \"探索现代 Web 开发的边界\",\n date: \"2023-10-24\",\n image: \"https://picsum.photos/id/1/800/600\",\n excerpt: \"随着技术的飞速发展,前端开发变得越来越复杂。本文将探讨如何在保持代码简洁的同时构建高性能应用。\",\n content: `\n \u003cp\u003eWeb 开发领域正在经历一场前所未有的变革。从简单的静态页面到复杂的单页应用(SPA),再到如今的服务器端渲染(SSR)和边缘计算,我们手中的工具越来越强大,但同时也带来了更多的挑战。\u003c/p\u003e\n \u003ch2\u003e性能优化的重要性\u003c/h2\u003e\n \u003cp\u003e在移动设备普及的今天,性能不再是锦上添花,而是生存之本。用户对于加载时间的容忍度极低。我们需要关注核心 Web 指标(Core Web Vitals),如 LCP、FID 和 CLS。\u003c/p\u003e\n \u003cp\u003e优化不仅仅是压缩代码,更包括合理的资源加载策略、图片优化以及高效的渲染路径。原生 JavaScript 的性能往往优于庞大的框架,因此在某些场景下,回归原生可能是一个明智的选择。\u003c/p\u003e\n \u003ch2\u003e保持代码的可维护性\u003c/h2\u003e\n \u003cp\u003e无论使用什么技术栈,代码的可读性和可维护性始终是关键。良好的命名规范、组件化设计以及清晰的文档,能让团队协作更加顺畅。\u003c/p\u003e\n \u003cp\u003eLorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.\u003c/p\u003e\n `\n },\n {\n id: 2,\n title: \"极简主义设计的艺术\",\n date: \"2023-10-18\",\n image: \"https://picsum.photos/id/20/800/600\",\n excerpt: \"少即是多。在信息过载的时代,极简设计不仅是一种美学选择,更是一种功能性的必需。\",\n content: `\n \u003cp\u003e极简主义(Minimalism)不仅仅意味着“少”,它意味着“恰到好处”。在设计中,每一个元素都应该有其存在的理由。多余的装饰会分散用户的注意力,降低信息的传递效率。\u003c/p\u003e\n \u003ch2\u003e留白的力量\u003c/h2\u003e\n \u003cp\u003e留白(White Space)是极简设计的核心。它不是浪费空间,而是为了突出内容,给用户的眼睛提供休息的区域。合理的留白能提升阅读体验,让页面看起来更加高端和精致。\u003c/p\u003e\n \u003cp\u003eDuis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\u003c/p\u003e\n \u003ch2\u003e色彩与排版\u003c/h2\u003e\n \u003cp\u003e在极简设计中,色彩通常被限制在少数几种,通常是一个主色调加上中性色。排版则成为了视觉的主角,字体的选择、大小、行高都至关重要。\u003c/p\u003e\n `\n },\n {\n id: 3,\n title: \"JavaScript 异步编程指南\",\n date: \"2023-10-10\",\n image: \"https://picsum.photos/id/48/800/600\",\n excerpt: \"从回调地狱到 Promise,再到 Async/Await,理解 JavaScript 的异步机制是成为高级开发者的必经之路。\",\n content: `\n \u003cp\u003eJavaScript 是单线程语言,这意味着它一次只能执行一个任务。为了处理耗时操作(如网络请求、文件读取),异步编程应运而生。\u003c/p\u003e\n \u003ch2\u003ePromises 的崛起\u003c/h2\u003e\n \u003cp\u003ePromise 对象代表一个异步操作的最终完成(或失败)及其结果值。它解决了回调地狱的问题,让代码逻辑更加清晰。我们可以使用 .then() 和 .catch() 来处理结果和错误。\u003c/p\u003e\n \u003cp\u003eUt enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.\u003c/p\u003e\n \u003ch2\u003eAsync/Await 的优雅\u003c/h2\u003e\n \u003cp\u003eAsync/Await 是基于 Promise 的语法糖,它让异步代码看起来像同步代码一样。这使得错误处理变得更加直观,可以使用 try/catch 块来捕获异常。\u003c/p\u003e\n `\n },\n {\n id: 4,\n title: \"CSS Grid 布局实战\",\n date: \"2023-09-28\",\n image: \"https://picsum.photos/id/60/800/600\",\n excerpt: \"告别浮动和定位,CSS Grid 带来了二维布局的革命。本文将通过实战案例展示 Grid 的强大之处。\",\n content: `\n \u003cp\u003eCSS Grid Layout 是 CSS 中最强大的布局系统。与 Flexbox(一维布局)不同,Grid 允许我们在行和列上同时控制元素的位置。\u003c/p\u003e\n \u003ch2\u003e基本概念\u003c/h2\u003e\n \u003cp\u003e要使用 Grid,首先需要将容器设置为 display: grid。然后,我们可以使用 grid-template-columns 和 grid-template-rows 来定义网格结构。\u003c/p\u003e\n \u003cp\u003eSed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo.\u003c/p\u003e\n \u003ch2\u003e响应式网格\u003c/h2\u003e\n \u003cp\u003eGrid 非常适合响应式设计。通过使用 auto-fit 和 minmax() 函数,我们可以轻松创建能够根据屏幕宽度自动调整列数的布局,而无需编写大量的媒体查询。\u003c/p\u003e\n `\n }\n ];\n\n // --- 应用程序逻辑 ---\n const app = {\n // 初始化\n init: function() {\n this.renderPostList();\n },\n\n // 渲染文章列表\n renderPostList: function() {\n const container = document.getElementById('post-container');\n container.innerHTML = ''; // 清空容器\n\n postsData.forEach(post =\u003e {\n const card = document.createElement('div');\n card.className = 'post-card';\n card.onclick = () =\u003e this.showDetail(post.id); // 绑定点击事件\n\n card.innerHTML = `\n \u003cimg src=\"${post.image}\" alt=\"${post.title}\" class=\"post-card-image\" loading=\"lazy\"\u003e\n \u003cdiv class=\"post-card-content\"\u003e\n \u003cdiv class=\"post-meta\"\u003e${post.date}\u003c/div\u003e\n \u003ch3 class=\"post-title\"\u003e${post.title}\u003c/h3\u003e\n \u003cp class=\"post-excerpt\"\u003e${post.excerpt}\u003c/p\u003e\n \u003cspan class=\"read-more\"\u003e阅读全文 →\u003c/span\u003e\n \u003c/div\u003e\n `;\n container.appendChild(card);\n });\n },\n\n // 渲染文章详情\n showDetail: function(id) {\n const post = postsData.find(p =\u003e p.id === id);\n if (!post) return;\n\n const container = document.getElementById('article-container');\n \n // 构建详情 HTML\n container.innerHTML = `\n \u003cheader class=\"article-header\"\u003e\n \u003ch1 class=\"article-title\"\u003e${post.title}\u003c/h1\u003e\n \u003cdiv class=\"post-meta\" style=\"font-size: 1rem;\"\u003e发布于 ${post.date}\u003c/div\u003e\n \u003c/header\u003e\n \u003cimg src=\"${post.image}\" alt=\"${post.title}\" class=\"article-cover\"\u003e\n \u003cdiv class=\"article-content\"\u003e\n ${post.content}\n \u003c/div\u003e\n `;\n\n // 切换视图\n this.switchView('detail');\n // 滚动到顶部\n window.scrollTo(0, 0);\n },\n\n // 返回首页\n showHome: function() {\n this.switchView('list');\n window.scrollTo(0, 0);\n },\n\n // 视图切换逻辑\n switchView: function(viewName) {\n const listView = document.getElementById('list-view');\n const detailView = document.getElementById('detail-view');\n\n if (viewName === 'list') {\n listView.classList.add('active');\n detailView.classList.remove('active');\n } else {\n listView.classList.remove('active');\n detailView.classList.add('active');\n }\n }\n };\n\n // 启动应用\n document.addEventListener('DOMContentLoaded', () =\u003e {\n app.init();\n });\n \u003c/script\u003e\n\u003c/body\u003e\n\u003c/html\u003e\n```\n\n### 代码说明:\n\n1. **HTML 结构**:\n * 页面分为 `header`(导航)、`main`(内容)和 `footer`(页脚)。\n * `main` 标签内包含两个主要的 `section`:`#list-view`(文章列表)和 `#detail-view`(文章详情)。通过 CSS 类 `.active` 来控制哪个视图显示。\n2. **CSS 样式**:\n * 使用了 CSS 变量 (`:root`) 来管理颜色,方便统一修改主题。\n * 使用了 **CSS Grid** (`display: grid`) 来实现文章列表的响应式布局,自动适应屏幕宽度。\n * 使用了 **Flexbox** 来处理导航栏和卡片内部的布局。\n * 添加了简单的 `@keyframes` 动画,使视图切换更加平滑。\n * 包含了针对移动端的媒体查询 (`@media`),确保在手机上也美观。\n3. **JavaScript 逻辑**:\n * **数据模拟**: 创建了一个 `postsData` 数组,包含文章的 ID、标题、日期、图片 URL、摘要和详细内容。\n * **状态管理**: 定义了一个 `app` 对象来封装逻辑。\n * **渲染函数**:\n * `renderPostList()`: 遍历数据数组,动态生成 HTML 卡片并插入页面。\n * `showDetail(id)`: 根据 ID 查找对应文章,生成详情页 HTML,并切换到详情视图。\n * **视图切换**: `switchView()` 函数通过添加/移除 CSS 类来隐藏或显示不同的 section,实现了类似单页应用(SPA)的效果,无需刷新页面。" } ] ``` ## 四、Go-cache缓存优化 在上一节中,我们实现了基本的Agent工厂模式,每次调用 `GetCodeGenAgent` 都会创建一个新的Agent实例。但在实际生产环境中,这会带来严重的性能问题。本节将详细说明如何使用 **go-cache** 库优化Agent实例的管理。 ### 为什么需要缓存优化? #### 问题场景:无缓存时的性能瓶颈 假设我的网站每分钟有100个用户同时对话: ``` 每分钟的请求: 100个用户 × 每人发送1条消息 = 100次调用 GetCodeGenAgent 每次调用: 1. 创建 RedisMemoryStore(分配内存、初始化连接) 2. 创建 CodeGenAgent(分配内存、初始化字段) 3. 配置 Redis Key、TTL等参数 耗时: 创建 RedisMemoryStore:~1ms 创建 CodeGenAgent:~0.5ms 总耗时:~1.5ms 每分钟总耗时: 100次 × 1.5ms = 150ms 看起来不多?但考虑以下问题: ``` **问题1:内存泄漏** ``` 每次创建新的Agent实例: Agent实例大小:~2KB(包含字段、指针等) 每分钟创建: 100个实例 × 2KB = 200KB 每小时: 200KB × 60 = 12MB 每天: 12MB × 24 = 288MB 如果不释放,内存会持续增长! ``` **问题2:GC压力** ``` 频繁创建和销毁对象: → 增加垃圾回收器的负担 → 导致GC停顿时间变长 → 影响整体性能 ``` ### 解决方案:使用go-cache缓存Agent实例 **核心思想:** > 对于同一个应用的同一个代码生成类型,Agent实例是可以复用的。因为Agent本身是无状态的,状态存储在Redis中。 ### go-cache库介绍 **go-cache** 是一个纯Go实现的内存缓存库,特点: | 特性 | 说明 | | ------------------ | ----------------------- | | **自动过期** | 支持TTL,过期自动删除 | | **LRU淘汰** | 支持自定义淘汰策略 | | **线程安全** | 内置锁,并发安全 | | **简单易用** | API简洁,Set/Get/Delete | **安装:** ```bash go get github.com/patrickmn/go-cache ``` **基本用法:** ```go import "github.com/patrickmn/go-cache" // 创建缓存,默认过期时间30分钟,清理间隔10分钟 c := cache.New(30*time.Minute, 10*time.Minute) // 设置缓存 c.Set("key", value, cache.DefaultExpiration) // 获取缓存 if x, found := c.Get("key"); found { value := x.(MyType) } // 删除缓存 c.Delete("key") ``` ### 完整的缓存优化实现 下面是对于缓存每个逻辑步骤的详细讲解,最后我提供了完整的修改内容 #### 全局缓存和实例计数 ```go package agent import ( "github.com/patrickmn/go-cache" "github.com/redis/go-redis/v9" "strconv" "sync" "time" "yikou-ai-go-teach/internal/ai/llm" "yikou-ai-go-teach/internal/service" "yikou-ai-go-teach/internal/store" "yikou-ai-go-teach/pkg/enum" ) // MaxAgentInstances 最大Agent实例数量 const MaxAgentInstances = 1000 var ( // serviceCache 全局缓存,存储Agent实例 // 默认过期时间:30分钟 // 清理间隔:10分钟 serviceCache = cache.New(30*time.Minute, 10*time.Minute) // instanceCount 当前实例数量 instanceCount int // instanceCountMu 实例计数锁(并发安全) instanceCountMu sync.Mutex ) ``` #### 工厂结构体定义 ```go type CodeGenAgentFactory struct { chatModel *llm.ChatModelWrapper redisClient *redis.Client chatHistoryService service.IChatHistoryService } func NewCodeGenAgentFactory( chatModel *llm.ChatModelWrapper, redisClient *redis.Client, chatHistoryService service.IChatHistoryService, ) *CodeGenAgentFactory { // 注册淘汰回调,记录日志 serviceCache.OnEvicted(func(k string, v interface{}) { logger.Debugf("AI服务实例被移除,缓冲键: %v", k) }) return &CodeGenAgentFactory{ chatModel: chatModel, redisClient: redisClient, chatHistoryService: chatHistoryService, } } ``` #### 缓存Key构建 ```go // buildCacheKey 构建缓存Key // 格式:{appId}_{codeGenType} // 示例:100_HtmlCodeGen, 200_MultiFileGen func buildCacheKey(appId int64, codeGenType enum.CodeGenTypeEnum) string { return strconv.Itoa(int(appId)) + "_" + string(codeGenType) } ``` #### LRU淘汰策略 当缓存实例数达到上限时,需要淘汰最老的实例: ```go // evictOldest 淘汰最老的缓存项(LRU策略) func (c CodeGenAgentFactory) evictOldest() { items := serviceCache.Items() oldestKey := "" var oldestExpiration int64 // 遍历所有缓存项,找到过期时间最早的 for k, item := range items { if item.Expiration == 0 { continue // 永不过期的项,跳过 } if oldestKey == "" || item.Expiration < oldestExpiration { oldestExpiration = item.Expiration oldestKey = k } } // 删除最老的项 if oldestKey != "" { serviceCache.Delete(oldestKey) instanceCountMu.Lock() instanceCount-- instanceCountMu.Unlock() } } ``` **LRU(Least Recently Used)策略:** ``` 假设缓存已满(1000个实例): 当前缓存项: Key1: 过期时间 10:00 Key2: 过期时间 10:05 Key3: 过期时间 10:10 ... 淘汰策略: 找到过期时间最早的 → Key1 删除 Key1 释放一个位置 新实例: 创建新的Agent实例 放入缓存 ``` #### 核心方法:GetCodeGenAgent ```go func (c CodeGenAgentFactory) GetCodeGenAgent( appId int64, codeGenType enum.CodeGenTypeEnum, ) (*CodeGenAgent, error) { redisStore := store.NewRedisMemoryStore( c.redisClient, strconv.Itoa(int(appId)), 20, 24*time.Hour, ) // 1. 构建缓存Key key := buildCacheKey(appId, codeGenType) // 2. 尝试从缓存获取 if agent, found := serviceCache.Get(key); found { // 缓存命中,直接返回 return agent.(*CodeGenAgent), nil } // 3. 缓存未命中,检查实例数量 instanceCountMu.Lock() if instanceCount >= MaxAgentInstances { // 达到上限,淘汰最老的实例 c.evictOldest() } instanceCountMu.Unlock() // 4. 创建新的Agent实例 agent := NewCodeGenAgent(c.chatModel, codeGenType, redisStore) // 5. 放入缓存 serviceCache.Set(key, agent, cache.DefaultExpiration) // 6. 更新实例计数 instanceCountMu.Lock() instanceCount++ instanceCountMu.Unlock() return agent, nil } ``` ### 性能对比分析 #### 无缓存 vs 有缓存 **场景:同一应用,连续100次调用** ``` 无缓存: 每次调用: 创建 RedisMemoryStore: 1ms 创建 CodeGenAgent: 0.5ms 总耗时: 1.5ms 100次调用: 总耗时: 100 × 1.5ms = 150ms 创建实例数: 100个 内存占用: 100 × 2KB = 200KB 有缓存: 第1次调用: 缓存未命中 创建实例: 1.5ms 放入缓存: 0.1ms 总耗时: 1.6ms 第2-100次调用: 缓存命中 查缓存: 0.01ms 总耗时: 0.01ms 100次调用: 总耗时: 1.6ms + 99 × 0.01ms = 2.59ms 创建实例数: 1个 内存占用: 1 × 2KB = 2KB 性能提升: 耗时:150ms → 2.59ms(快58倍) 实例数:100 → 1(减少99%) 内存:200KB → 2KB(减少99%) ``` ### 并发安全性分析 #### 多个请求同时调用GetCodeGenAgent ``` 请求1: GetCodeGenAgent(100, HtmlCodeGen) 请求2: GetCodeGenAgent(100, HtmlCodeGen) ← 同时到达 请求3: GetCodeGenAgent(200, HtmlCodeGen) 可能的竞态条件: 请求1和请求2同时发现缓存未命中 → 都创建新的Agent实例 → 都尝试放入缓存 → 重复创建 ``` **解决方案:instanceCountMu互斥锁** ```go instanceCountMu.Lock() if instanceCount >= MaxAgentInstances { c.evictOldest() } instanceCountMu.Unlock() ``` **注意:这里的锁只保护实例计数,不保护缓存操作** go-cache内部已经实现了线程安全: ```go // go-cache源码(简化) func (c *cache) Set(k string, x interface{}, d time.Duration) { c.mu.Lock() // 内部锁 // ... 设置操作 c.mu.Unlock() } func (c *cache) Get(k string) (interface{}, bool) { c.mu.Lock() // 内部锁 // ... 获取操作 c.mu.Unlock() } ``` ### 完整代码 ```go package agent import ( "github.com/bytedance/gopkg/util/logger" "github.com/patrickmn/go-cache" "github.com/redis/go-redis/v9" "strconv" "sync" "time" "yikou-ai-go-teach/internal/ai/llm" "yikou-ai-go-teach/internal/service" "yikou-ai-go-teach/internal/store" "yikou-ai-go-teach/pkg/enum" ) const MaxAgentInstances = 1000 var ( serviceCache = cache.New(30*time.Minute, 10*time.Minute) instanceCount int instanceCountMu sync.Mutex ) type CodeGenAgentFactory struct { chatModel *llm.ChatModelWrapper redisClient *redis.Client chatHistoryService service.IChatHistoryService } func NewCodeGenAgentFactory( chatModel *llm.ChatModelWrapper, redisClient *redis.Client, chatHistoryService service.IChatHistoryService, ) *CodeGenAgentFactory { serviceCache.OnEvicted(func(k string, v interface{}) { logger.Debugf("AI服务实例被移除,缓冲键: %v", k) }) return &CodeGenAgentFactory{ chatModel: chatModel, redisClient: redisClient, chatHistoryService: chatHistoryService, } } func (c CodeGenAgentFactory) evictOldest() { items := serviceCache.Items() oldestKey := "" var oldestExpiration int64 for k, item := range items { if item.Expiration == 0 { continue } if oldestKey == "" || item.Expiration < oldestExpiration { oldestExpiration = item.Expiration oldestKey = k } } if oldestKey != "" { serviceCache.Delete(oldestKey) instanceCountMu.Lock() instanceCount-- instanceCountMu.Unlock() } } func buildCacheKey(appId int64, codeGenType enum.CodeGenTypeEnum) string { return strconv.Itoa(int(appId)) + "_" + string(codeGenType) } func (c CodeGenAgentFactory) GetCodeGenAgent(appId int64, codeGenType enum.CodeGenTypeEnum) (*CodeGenAgent, error) { redisStore := store.NewRedisMemoryStore(c.redisClient, strconv.Itoa(int(appId)), 20, 24*time.Hour) key := buildCacheKey(appId, codeGenType) // 查缓存 if agent, found := serviceCache.Get(key); found { return agent.(*CodeGenAgent), nil } // 检查实例数 instanceCountMu.Lock() if instanceCount >= MaxAgentInstances { c.evictOldest() } instanceCountMu.Unlock() // 创建新实例 agent := NewCodeGenAgent(c.chatModel, codeGenType, redisStore) // 放入缓存 serviceCache.Set(key, agent, cache.DefaultExpiration) // 更新计数 instanceCountMu.Lock() instanceCount++ instanceCountMu.Unlock() return agent, nil } ``` ## 五、加载对话历史到Eino智能体的对话记忆中 在前面的章节中,我们实现了对话历史的MySQL持久化和Eino对话记忆的Redis存储。但存在一个关键问题:**如果Redis数据丢失(重启、过期、故障),对话记忆如何恢复?** 本节将详细说明如何从MySQL加载对话历史到Eino智能体的对话记忆中,实现**容灾恢复**和**冷启动优化**。 ### 为什么需要加载历史到记忆? **问题场景:Redis数据丢失** ``` 场景1:TTL过期 24小时未使用应用 → Redis Key过期被删除 用户重新使用应用 → AI从零开始 → 丢失之前的对话背景 场景2:Redis故障 Redis服务宕机 → 无法读取对话记忆 降级处理 → AI无历史上下文 → 体验下降 ``` **解决方案:从MySQL恢复到Redis** ### Service接口新增方法 **文件位置:** `internal/service/chat_history_service.go` ```go type IChatHistoryService interface { AddChatMessage(ctx context.Context, appId int64, message string, messageType enum.ChatHistoryMessageTypeEnum, userId int64) error DeleteByAppId(ctx context.Context, appId int64) error ListAppChatHistoryByPage(ctx context.Context, appId int64, pageSize int32, lastCreateTime time.Time, loginUser *vo.UserVo) (*response.PageResponse[*model.ChatHistory], error) ListAllChatHistoryByPageForAdmin(ctx context.Context, pageNum int32, pageSize int32, queryRequest *api.YiKouChatHistoryQueryRequest) (*response.PageResponse[*model.ChatHistory], error) LoadChatHistoryToMemory(ctx context.Context, appId int64, memoryStore store.MemoryStore, maxCount int) (int, error) } ``` ### Logic层实现 **文件位置:** `internal/logic/chat_history_logic.go` ```go func (s *ChatHistoryService) LoadChatHistoryToMemory( ctx context.Context, appId int64, memoryStore store.MemoryStore, maxCount int, ) (int, error) { // 1. 查询摘要消息(如果有) historySummary, err := query.Use(s.db).ChatHistory. Where( query.ChatHistory.AppID.Eq(appId), query.ChatHistory.MessageType.Eq(string(enum.SummaryMessageType)), ). Order(query.ChatHistory.CreateTime.Desc()). Limit(maxCount). Find() var historyList []*model.ChatHistory // 2. 根据摘要情况决定加载策略 if err != nil && historySummary == nil { // 2.1 无摘要:加载最近的对话历史 historyList, err = query.Use(s.db).ChatHistory. Where(query.ChatHistory.AppID.Eq(appId)). Order(query.ChatHistory.CreateTime.Desc()). Limit(maxCount). Find() if err != nil { return 0, err } // 排除第一条(因为第一条是当前正在进行的对话) if len(historyList) > 1 { historyList = historyList[1:] } } else { // 2.2 有摘要:只加载摘要消息 historyList = historySummary } // 3. 清空MemoryStore(避免重复) err = memoryStore.ClearMessages(ctx) if err != nil { return 0, err } // 4. 按时间正序添加消息(从旧到新) loadedCount := 0 for i := len(historyList) - 1; i >= 0; i-- { history := historyList[i] // 根据消息类型创建不同的Message if history.MessageType == string(enum.UserMessageType) { err = memoryStore.AppendMessage(ctx, schema.UserMessage(history.Message)) if err != nil { return loadedCount, err } loadedCount++ } else if history.MessageType == string(enum.AIMessageType) { err = memoryStore.AppendMessage(ctx, schema.AssistantMessage(history.Message, nil)) if err != nil { return loadedCount, err } loadedCount++ } else if history.MessageType == string(enum.SummaryMessageType) { err = memoryStore.AppendMessage(ctx, schema.SystemMessage(history.Message)) if err != nil { return loadedCount, err } loadedCount++ } } return loadedCount, nil } ``` ### 修改Factory获取agent方法 **文件位置:** `internal/ai/agent/codegen_agent_factory.go` ```go func (c CodeGenAgentFactory) GetCodeGenAgent( appId int64, codeGenType enum.CodeGenTypeEnum, ) (*CodeGenAgent, error) { // 1. 创建RedisMemoryStore redisStore := store.NewRedisMemoryStore( c.redisClient, strconv.Itoa(int(appId)), 20, 24*time.Hour, ) // 2. 新增:从MySQL加载对话历史到Redis _, err := c.chatHistoryService.LoadChatHistoryToMemory( context.Background(), appId, redisStore, 20, ) if err != nil { return nil, err } if err != nil { return nil, err } // 3. 构建缓存Key key := buildCacheKey(appId, codeGenType) // 4. 尝试从缓存获取 if agent, found := serviceCache.Get(key); found { return agent.(*CodeGenAgent), nil } // 5. 检查实例数量 instanceCountMu.Lock() if instanceCount >= MaxAgentInstances { c.evictOldest() } instanceCountMu.Unlock() // 6. 创建CodeGenAgent agent := NewCodeGenAgent(c.chatModel, codeGenType, redisStore) // 7. 放入缓存 serviceCache.Set(key, agent, cache.DefaultExpiration) // 8. 更新计数 instanceCountMu.Lock() instanceCount++ instanceCountMu.Unlock() return agent, nil } ``` ### 重新测试 若Redis之前还保留着对话记忆,你可以直接删除键值对,然后重新询问智能体你还记得我刚刚要求你干什么吗,测试是否能回答出 ![在这里插入图片描述](https://pic.code-nav.cn/post_picture/1969049470100254722/aYQBrlKZt9UPHXrv.webp) 可以看出来,加载对话历史的功能成功 ## 六、使用Redis优化用户登录功能 在对话历史模块中,我们使用Redis作为对话记忆的存储介质。为了保持技术栈的一致性,本节将用户登录功能也迁移到Redis,实现统一的会话管理方案。 ### 为什么使用Redis存储用户登录状态? #### 传统Session方案的问题 ```go // 全局map存储session var sessions = make(map[string]*User) func Login(user *User) string { sessionId := generateSessionId() sessions[sessionId] = user // 存储在内存中 return sessionId } ``` **问题:** - **单机限制**:无法支持多实例部署 - **重启丢失**:服务重启后所有用户需要重新登录 - **内存泄漏**:长时间运行可能导致内存溢出 - **无法共享**:多个服务实例无法共享session #### Redis方案的优势 1. **高性能**:Redis基于内存,读写速度极快(微秒级) 2. **自动过期**:TTL机制自动清理过期session,无需手动维护 3. **分布式支持**:多个服务实例共享同一个Redis,实现分布式session 4. **持久化**:Redis支持RDB/AOF持久化,重启不丢失数据 5. **高可用**:支持主从复制、哨兵、集群模式 ### Service 层修改 **文件位置:** `internal/logic/user_logic.go` #### UserService 结构体修改 注入 `redisClient` 依赖: ```go type UserService struct { db *gorm.DB redisClient *redis.Client // ← 新增:Redis客户端 } func NewUserService(db *gorm.DB, redisClient *redis.Client) *UserService { return &UserService{ db: db, redisClient: redisClient, } } ``` #### UserLogin 方法:用户登录 **功能说明:** 用户登录成功后,将用户信息存入Redis,并返回sessionId。 **完整代码:** ```go func (s *UserService) UserLogin(ctx context.Context, req *api.YiKouUserLoginRequest, c *app.RequestContext) (vo.UserVo, error) { // 1. 校验参数 if req.UserAccount == "" || req.UserPassword == "" { return vo.UserVo{}, errorutil.ParamsError } // 2. 校验用户是否存在 user, err := query.Use(s.db).User.Where(query.User.UserAccount.Eq(req.UserAccount)).First() if err != nil { return vo.UserVo{}, err } // 3. 校验密码是否正确 encryptPassword := s.GetEncryptPassword(ctx, req.UserPassword) if user.UserPassword != encryptPassword { return vo.UserVo{}, errorutil.ParamsError.WithMessage("密码错误") } // 4. 生成 sessionId sessionId := fmt.Sprintf("session:%d", time.Now().UnixNano()) // 关键步骤:将用户信息转换为json并存入Redis userJson, err := json.Marshal(user) if err != nil { return vo.UserVo{}, err } err = s.redisClient.Set(ctx, sessionId, string(userJson), 24*time.Hour).Err() if err != nil { return vo.UserVo{}, err } // 6. 保存sessionId到cookie c.SetCookie(constants.UserLoginState, sessionId, 86400, "/", "", protocol.CookieSameSiteLaxMode, false, true) // 7. 构建userVo对象 loginUserVo := vo.UserVo{ ID: user.ID, UserAccount: user.UserAccount, UserName: user.UserName, UserAvatar: user.UserAvatar, UserProfile: user.UserProfile, UserRole: user.UserRole, CreateTime: user.CreateTime, UpdateTime: user.UpdateTime, } return loginUserVo, nil } ``` #### GetLoginUserVo 方法:获取登录用户信息 **功能说明:** 从Redis中获取当前登录用户的详细信息。 **完整代码:** ```go func (s *UserService) GetLoginUserVo(ctx context.Context, c *app.RequestContext) (vo.UserVo, error) { // 1. 获取sessionId(从Cookie中) sessionId := c.Request.Header.Cookie(constants.UserLoginState) if sessionId == nil { return vo.UserVo{}, errorutil.ParamsError } // 2. URL解码sessionId decodedSessionId, err := url.QueryUnescape(string(sessionId)) if err != nil { return vo.UserVo{}, err } // 关键步骤:从Redis获取用户信息 userJson, err := s.redisClient.Get(ctx, decodedSessionId).Result() if err != nil { return vo.UserVo{}, errorutil.ParamsError.WithMessage("登录已过期,请重新登录") } // 4. 反序列化用户信息 var user model.User err = json.Unmarshal([]byte(userJson), &user) if err != nil { return vo.UserVo{}, err } // 5. 校验用户是否存在(双重校验) _, err = query.Use(s.db).User.Where(query.User.ID.Eq(user.ID), query.User.IsDelete.Eq(0)).First() if err != nil { return vo.UserVo{}, err } // 6. 构建 UserVo loginUserVo := vo.UserVo{ ID: user.ID, UserAccount: user.UserAccount, UserName: user.UserName, UserAvatar: user.UserAvatar, UserProfile: user.UserProfile, UserRole: user.UserRole, CreateTime: user.CreateTime, UpdateTime: user.UpdateTime, } return loginUserVo, nil } ``` #### Logout 方法:用户登出 **功能说明:** 从Redis中删除用户session,实现登出功能。 **完整代码:** ```go func (s *UserService) Logout(ctx context.Context, c *app.RequestContext) error { // 1. 获取sessionId sessionId := c.Request.Header.Cookie(constants.UserLoginState) if sessionId == nil { return errorutil.ParamsError.WithMessage("用户未登录") } // 2. URL解码sessionId decodedSessionId, err := url.QueryUnescape(string(sessionId)) if err != nil { return err } // 关键步骤:从Redis删除session _ = s.redisClient.Del(ctx, decodedSessionId).Err() // 4. 清除Cookie c.SetCookie(constants.UserLoginState, "", 0, "/", "", protocol.CookieSameSiteLaxMode, false, true) return nil } ``` ### Middleware修改 **文件位置:** `internal/middleware/auth.go` #### AuthMiddleware 函数修改 注入 `redisClient` 参数,从Redis获取用户信息进行鉴权。 **完整代码:** ```go // AuthMiddleware 鉴权中间件 func AuthMiddleware(roleEnum enum.UserRoleEnum, db *gorm.DB, redisClient *redis.Client) app.HandlerFunc { return func(ctx context.Context, c *app.RequestContext) { // 1. 校验权限 var decodeUser []byte if roleEnum != "" { // 2. 获取sessionId(从Cookie中) sessionId := c.Request.Header.Cookie(constants.UserLoginState) if sessionId == nil { c.JSON(200, errorutil.NotLoginError) c.Abort() return } // 3. URL解码sessionId decodedSessionId, err := url.QueryUnescape(string(sessionId)) if err != nil { c.JSON(200, errorutil.NotAuthError) c.Abort() return } // 关键步骤:从Redis获取用户信息 userJsonStr, err := redisClient.Get(ctx, decodedSessionId).Result() if err != nil { c.JSON(200, errorutil.NotLoginError.WithMessage("登录已过期,请重新登录")) c.Abort() return } decodeUser = []byte(userJsonStr) } // 5. 解析用户信息 var user model.User err := json.Unmarshal(decodeUser, &user) if err != nil { c.JSON(200, errorutil.SystemError.WithMessage(err.Error())) c.Abort() return } // 6. 校验用户权限等级是否符合要求 dbUser, err := query.Use(db).User.Where(query.User.ID.Eq(user.ID), query.User.IsDelete.Eq(0)).First() if err != nil { c.JSON(200, errorutil.NotAuthError) c.Abort() return } // 7. 校验角色权限 if roleEnum == enum.AdminRole && enum.UserRoleEnum(dbUser.UserRole) != roleEnum { c.JSON(200, errorutil.NotAuthError) c.Abort() return } c.Next(ctx) } } ``` ### Router 层修改 **文件位置:** `internal/router/router.go` #### RegisterRoutes 函数修改 在路由注册函数中注入 `redisClient` 参数,并传递给中间件。 **完整代码:** ```go // RegisterRoutes 注册路由 func RegisterRoutes(h *server.Hertz, url func(config *swagger.Config), db *gorm.DB, redisClient *redis.Client, userHandler *handler.UserHandler, appHandler *handler.AppHandler, chatHistoryHandler *handler.ChatHistoryHandler) { // 注册全局中间件 // 处理跨域问题 h.Use(cors.New(cors.Config{ AllowAllOrigins: true, AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, AllowHeaders: []string{"Origin", "Content-Type", "Authorization"}, ExposeHeaders: []string{"Content-Length"}, AllowCredentials: false, MaxAge: 12 * time.Hour, })) // 全局异常处理 h.Use(recovery.Recovery(recovery.WithRecoveryHandler(CustomRecoveryHandler))) // 测试接口 h.GET("/ping", handler.Ping) // swaggo文档 h.GET("/swagger/*any", swagger.WrapHandler(swaggerFiles.Handler, url)) userRoute := h.Group("/user") { userRoute.POST("/register", userHandler.UserRegister) userRoute.POST("/login", userHandler.UserLogin) userRoute.GET("/get/vo", userHandler.GetUserVo) // 需要登录的接口(传递redisClient) userRoute.GET("/get/login", middleware.AuthMiddleware(enum.UserRole, db, redisClient), userHandler.GetLoginUser) userRoute.POST("/logout", middleware.AuthMiddleware(enum.UserRole, db, redisClient), userHandler.Logout) // 需要管理员权限的接口(传递redisClient) userRoute.POST("/add", middleware.AuthMiddleware(enum.AdminRole, db, redisClient), userHandler.AddUser) userRoute.GET("/get", middleware.AuthMiddleware(enum.AdminRole, db, redisClient), userHandler.GetUser) userRoute.POST("/delete", middleware.AuthMiddleware(enum.AdminRole, db, redisClient), userHandler.DeleteUser) userRoute.POST("/update", middleware.AuthMiddleware(enum.AdminRole, db, redisClient), userHandler.UpdateUser) userRoute.POST("/list/page/vo", middleware.AuthMiddleware(enum.AdminRole, db, redisClient), userHandler.ListUserVoByPage) } appRoute := h.Group("/app") { appRoute.POST("/good/list/page/vo", appHandler.ListGoodApp) appRoute.GET("/get/vo", middleware.AuthMiddleware(enum.UserRole, db, redisClient), appHandler.GetAppVo) // 需要登录的接口(传递redisClient) appRoute.GET("/chat/gen/code", middleware.AuthMiddleware(enum.UserRole, db, redisClient), appHandler.ChatToGenCode) appRoute.POST("/my/list/page/vo", middleware.AuthMiddleware(enum.UserRole, db, redisClient), appHandler.ListMyApp) appRoute.POST("/add", middleware.AuthMiddleware(enum.UserRole, db, redisClient), appHandler.AddApp) appRoute.POST("/update", middleware.AuthMiddleware(enum.UserRole, db, redisClient), appHandler.UpdateApp) appRoute.POST("/delete", middleware.AuthMiddleware(enum.UserRole, db, redisClient), appHandler.DeleteApp) // 需要管理员权限的接口(传递redisClient) appRoute.POST("/admin/update", middleware.AuthMiddleware(enum.AdminRole, db, redisClient), appHandler.AdminUpdateApp) appRoute.POST("/admin/delete", middleware.AuthMiddleware(enum.AdminRole, db, redisClient), appHandler.AdminDeleteApp) appRoute.GET("/admin/get/vo", middleware.AuthMiddleware(enum.AdminRole, db, redisClient), appHandler.AdminGetAppVo) appRoute.POST("/admin/list/page/vo", middleware.AuthMiddleware(enum.AdminRole, db, redisClient), appHandler.AdminListApp) } // 聊天历史路由 chatHistoryRoute := h.Group("/chatHistory") { // 需要管理员权限的接口(传递redisClient) chatHistoryRoute.POST("/admin/list/page/vo", middleware.AuthMiddleware(enum.AdminRole, db, redisClient), chatHistoryHandler.ListAllChatHistoryByPageForAdmin) chatHistoryRoute.GET("/app/:appId", middleware.AuthMiddleware(enum.UserRole, db, redisClient), chatHistoryHandler.ListAppChatHistory) } } ``` ### Wire 依赖注入修改 **文件位置:** `wire/wire.go` ```go // initServer 初始化 Web 服务器 func initServer(cfg *config.Config, userHandler *handler.UserHandler, appHandler *handler.AppHandler, db *gorm.DB, redisClient *redis.Client, chatHistoryHandler *handler.ChatHistoryHandler) *server.Hertz { // 动态设置 Swagger 信息 docs.SwaggerInfo.Host = fmt.Sprintf("localhost:%d", cfg.Server.Port) docs.SwaggerInfo.BasePath = cfg.Server.ContextPath // 初始化swagger路径 swaggerPath := fmt.Sprintf("http://localhost:%d%s/swagger/doc.json", cfg.Server.Port, cfg.Server.ContextPath) url := swagger.URL(swaggerPath) // 创建 Hertz 服务器 h := server.Default( server.WithHostPorts(":"+strconv.Itoa(cfg.Server.Port)), server.WithBasePath(cfg.Server.ContextPath), ) // 注册路由 router.RegisterRoutes(h, url, db, redisClient, userHandler, appHandler, chatHistoryHandler) return h } ``` **在wire目录下执行生成命令** ```bash cd wire wire ``` 到这里,本章的教学内容就结束,这一章的内容量特别大,希望各位读者能好好消化。下一章我会增加难度,讲解如何实现智能体生成vue工程化项目代码。要是对该教程感兴趣的,可以star一下仓库 [https://github.com/FeiWuSama/yikou-ai-go](https://github.com/FeiWuSama/yikou-ai-go) 给予博主更多支持哦,谢谢各位看到这里的读者!

LangChain4j 调用 DeepSeek 工具时报 400?用 pi 抓包定位,同包覆盖修复 reasoning_content

## 背景 使用 LangChain4j 搭配 OpenAI 的 starter 进行工具调用时,会出现 `400 Bad Request`: ```json {"error":{"message":"The `reasoning_content` in the thinking mode must be passed back to the API",...}} ``` ## 问题分析 出现这个问题,是因为发送的 request body 不符合 DeepSeek 的规范。那么缺少的是哪一个字段?光靠猜并不靠谱,这里我们用一个非常简易的 Agent 框架 pi 来抓真实请求体。 ### 安装 pi pi 的安装非常简单: ```bash npm install -g --ignore-scripts @earendil-works/pi-coding-agent ``` 安装之后,在终端输入 `pi` 即可看到: ![image.png](https://pic.code-nav.cn/post_picture/1608460212774109186/GPJzw9F500f0AYpV.webp) ### 配置 DeepSeek API Key 配置 API Key 也很简单:输入 `/login` → `Use an API Key` → 选择 DeepSeek,再输入[密钥](https://platform.deepseek.com)。 ![image.png](https://pic.code-nav.cn/post_picture/1608460212774109186/fyKI8tvh95V9DcLe.webp) 输入密钥后回车确认即可: ![image.png](https://pic.code-nav.cn/post_picture/1608460212774109186/bGhqXR0ljuTs9GoW.png) ### 用扩展抓取真实请求体 下面是我让 pi 生成 `ai-request-logger` 扩展用的 Prompt,它会把所有 AI provider 请求/响应落盘到 `.pi/ai-request-logger/` 下。 ```markdown # Prompt: 生成 ai-request-logger 扩展 为 pi coding agent 创建扩展,拦截并记录所有 AI provider 请求/响应到 `.pi/ai-request-logger/` 目录下按日期分 `.jsonl` 文件。 **核心功能:** 1. `before_provider_request` → 记录请求 ID、模型、消息数、payload 大小 2. `after_provider_response` → 追加状态码、延迟 3. `message_end` → 追加 token 用量、费用 4. `turn_end` → 汇总本轮统计,footer 显示 5. 注册 `/ai-log` 命令 → custom UI 面板查看日志(滚动/展开) 6. 注册 `query_logs` 工具 → LLM 可查询统计/历史 7. 注册 `log_level` 工具 → LLM 调整日志级别 **实现要求:** - 内存维护 `RequestLog[]` 和 `TurnSummary[]`,上限 1000 条 - 文件写用 `fs.promises.appendFile`,不阻塞 - 完整 payload 仅 verbose 模式存储 - 参考示例:`provider-payload.ts`、`todo.ts`、`summarize.ts`、`model-status.ts` - 所有 I/O try-catch 包裹,不抛异常阻塞主流程 ``` 通过 pi 进行工具调用时,他就会吧日志信息记录到 .pi 文件夹下面: ![image.png](https://pic.code-nav.cn/post_picture/1608460212774109186/9dvgyR0edalI5bXx.webp) 我们可以看到工具调用会添加一个 `reasoning_content` 字段,并且这个 content 字段为 null 也不影响: ![image.png](https://pic.code-nav.cn/post_picture/1608460212774109186/YAXMTOfOgIGeBzDS.webp) ### 定位根因 我们查看自己的请求体,发现没有这个参数,所以报错就是因为缺少 `reasoning_content`。知道原因后,修改就容易了。 ## 修复 Bug 通过同包名覆盖 `dev.langchain4j.model.openai.OpenAiChatModel`,在 DeepSeek 模型分支下回传 `reasoning_content` 字段。我们使用同包名覆盖源码的方式实现:JVM 类加载时,工程内同包同名类会优先于依赖包中的版本。这种方式虽然不利于版本升级,但最直接有效。灵感来源于[「AI 零代码项目」](https://www.codefather.cn/course/1948291549923344386/section/1955850416717950978),具体方法如下: 1. 首先找到相对底层的类 `dev.langchain4j.model.openai.OpenAiChatModel` 2. 在项目的同包路径 `src/main/java/dev/langchain4j/model/openai/` 下创建同名类 `OpenAiChatModel`,并把源码内容复制过来 3. 之后可以让 AI 进行修改,可以使用 Cursor 或者 Codex 之类的,让工具调用时添加上 `reasoning_content` 这个参数 4. 下面是我修改好的代码片段,完整版本见 GitHub 上的 [`OpenAiChatModel.java`](https://github.com/lieeew/deepseek-langchain4j/blob/main/src/main/java/dev/langchain4j/model/openai/OpenAiChatModel.java): ```java @Override public ChatResponse doChat(ChatRequest chatRequest) { OpenAiChatRequestParameters parameters = (OpenAiChatRequestParameters) chatRequest.parameters(); validate(parameters); String modelName = parameters.modelName(); List<Message> messages = isDeepSeekModel(modelName) ? toOpenAiMessages(chatRequest.messages(), sendThinking, thinkingFieldName) : OpenAiUtils.toOpenAiMessages(chatRequest.messages(), sendThinking, thinkingFieldName); ChatCompletionRequest openAiRequest = toOpenAiChatRequest( chatRequest, parameters, sendThinking, thinkingFieldName, strictTools, strictJsonSchema) .messages(messages) .build(); .... .... } private static boolean isDeepSeekModel(String modelName) { return modelName != null && modelName.toLowerCase().contains("deepseek"); } /** * DeepSeek V4 thinking + tool_calls:含 tool_calls 的 assistant 必须回传 reasoning_content(无则 "")。 */ private static List<Message> toOpenAiMessages( List<ChatMessage> messages, boolean sendThinking, String thinkingFieldName) { return messages.stream() .map(message -> toOpenAiMessage(message, sendThinking, thinkingFieldName)) .collect(toList()); } private static Message toOpenAiAssistantWithToolReasoning(AiMessage aiMessage, String thinkingFieldName) { String reasoning = aiMessage.thinking(); if (reasoning == null) { reasoning = ""; } ToolExecutionRequest first = aiMessage.toolExecutionRequests().get(0); if (first.id() == null) { FunctionCall functionCall = FunctionCall.builder() .name(first.name()) .arguments(first.arguments()) .build(); return AssistantMessage.builder() .functionCall(functionCall) .customParameter(thinkingFieldName, reasoning) .build(); } List<ToolCall> toolCalls = aiMessage.toolExecutionRequests().stream() .map(it -> ToolCall.builder() .id(it.id()) .type(FUNCTION) .function(FunctionCall.builder() .name(it.name()) .arguments(isNullOrBlank(it.arguments()) ? "{}" : it.arguments()) .build()) .build()) .collect(toList()); return AssistantMessage.builder() .content(aiMessage.text()) .toolCalls(toolCalls) .customParameter(thinkingFieldName, reasoning) .build(); } ``` ## 测试 [Github](https://github.com/lieeew/deepseek-langchain4j) 上面提供了一个简单的 demo,测试发现是可以的非常成功!通过日志可以看到正确携带了 `reasoning_content` 字段。 ![image.png](https://pic.code-nav.cn/post_picture/1608460212774109186/L6WLRIsjs93wb32W.webp)

我把 AI 零代码项目上线并改造了

# 我把 AI 零代码项目上线并改造了 半年多前,我跟着 [AI 零代码应用生成平台](https://www.codefather.cn/course/1948291549923344386) 完成了项目的大部分功能并上线了。教程版本已经跑通了从自然语言描述到代码生成、预览和部署的基本流程。最近闲来无事,我又重新捡起这个项目,从界面、生成过程、代码查看、React 构建和线上稳定性等方面做了几轮改造。 **在线体验:[https://zerocode.1000ye.top](https://zerocode.1000ye.top)** **GitHub 后端仓库:[https://github.com/X1aoM1ngTX/zerocode-backend](https://github.com/X1aoM1ngTX/zerocode-backend)** ![Vexor-HomePage.png](https://pic.code-nav.cn/post_picture/1828801572948791297/N3th98VYtrVeiIXG.webp) > Vexor 首页:输入一段应用描述,就可以开始生成项目。 这篇文章不准备逐行讲代码,主要记录我为什么要做这些改动、具体是怎么处理的,以及项目从“能运行”到“能上线”过程中遇到的一些实际问题。 --- ## 先把前端界面重新整理了一遍 最早的页面主要围绕功能实现,能用,但整体比较像后台管理系统。不同页面的布局、颜色和交互方式也不够统一,用户从首页进入应用生成页面时,会有比较明显的割裂感。 这次调整前端时,我没有一开始就让 AI 直接修改代码,而是先整理了一张比较粗糙的草图。草图里不关注字体、颜色和间距,主要标出每个页面需要展示什么,以及不同区域之间的关系: - 首页需要突出应用描述输入框和常用模板 - 登录、注册和重置密码页面保持统一 - 应用生成页面采用左右布局,左侧负责对话,右侧负责预览和代码 - “我的作品”和“精选案例”复用相同的卡片布局 - 顶部导航在主要页面中保持一致 ![Vexor-PreDesign.png](https://pic.code-nav.cn/post_picture/1828801572948791297/ZW9F9y3UmdS2suKS.webp) > 最开始整理的页面草图,重点是确定信息结构和功能区域。 有了草图后,我把它交给 ChatGPT,让它根据现有品牌 Logo、蓝紫色主色调和 AI 产品的定位生成一版更完整的视觉设计。生成的设计图补充了字体层级、卡片样式、背景效果和深色应用工作区,也让我能在真正改代码前先判断整体方向是否合适。 ![Vexor-ChatGPT-Design.png](https://pic.code-nav.cn/post_picture/1828801572948791297/I3LostOA8snrullr.webp) > ChatGPT 根据草图生成的视觉方案,包含首页、认证页面和应用工作区。 设计方向确定后,我再把草图、设计图和现有项目代码一起交给 Codex,让它按照 Vue 3 和 Ant Design Vue 的现有结构逐步落地。这个过程中并不是直接照着图片还原,而是需要结合真实功能继续调整: - 将设计图中的颜色、圆角和阴影整理成全局 CSS 变量 - 保留已有接口、权限判断和页面路由,避免只改外观却破坏功能 - 把重复的加载状态、空状态和错误处理整理成公共组件与组合式函数 - 根据真实内容长度调整布局,并补充移动端适配 - 将应用生成页的对话、预览和代码区域接入实际数据 这套流程里,草图负责表达我的想法,ChatGPT 帮助探索视觉方向,Codex 则负责结合现有代码完成实现。最终页面没有完全照搬设计图,但整体布局和视觉风格有了统一的参考,也减少了边写代码边反复试样式的时间。 --- ## 增加提示词优化和文生图 很多用户第一次使用 AI 生成应用时,只会输入一句比较简单的描述,比如“帮我做一个个人博客”。这种表达没有问题,但里面缺少页面结构、视觉风格和具体功能,模型只能自行猜测,最后生成的结果往往比较普通,也未必符合用户真正想要的方向。 所以我在首页和应用对话页增加了提示词优化功能。用户输入简单想法后,可以先让单独的提示词优化模型补充页面组成、交互方式和设计要求,再决定是否使用优化后的内容开始生成。 例如,“帮我做一个个人博客”可以被补充为包含首页、文章列表、文章详情、分类筛选、响应式布局和视觉风格的完整需求。它不是替用户决定所有细节,而是帮助用户把脑海里比较模糊的想法表达清楚。 这部分使用了独立的系统提示词和模型配置,没有直接占用代码生成模型的上下文。优化完成后,结果会回填到输入框中,用户仍然可以继续修改,而不是立刻开始生成。 相关实现主要在: ```text vexor-frontend/src/pages/HomePage.vue vexor-frontend/src/pages/app/AppChatPage.vue vexor-backend/src/main/java/com/xm/vexorbackend/ai/PromptOptimizerService.java vexor-backend/src/main/resources/prompt/prompt-optimizer-system-prompt.txt ``` 除此之外,项目还接入了阿里云 Z-Image-Turbo,增加了单独的文生图页面。除了正向提示词,页面还支持负向提示词、图片尺寸和随机种子等参数,方便重复生成或控制结果。 ![Vexor-ImageGen.png](https://pic.code-nav.cn/post_picture/1828801572948791297/KmrIOigPoknRzrep.webp) > 文生图页面会同时展示当前提示词、生成参数和最终图片。 文生图接口调用成本比较高,也容易被频繁请求,因此后端给这个接口增加了登录校验和 IP 限流。目前同一个 IP 每小时最多生成 8 张图片,避免接口被持续调用后产生不可控的费用。 --- ## 让代码生成过程真正“看得见” 最近一次改动最大的地方,是应用生成页面。 之前生成代码时,如果生成的是 Vue 或 React 项目,AI 的回答和代码内容会混在一起返回。一条消息中可能同时包含项目说明、十几个文件的路径以及完整代码,数据量很容易超过 MySQL `TEXT` 字段的存储上限,最终导致聊天内容被截断或保存失败。 即使成功保存,把所有代码都塞进聊天记录也不方便使用。用户需要在一段很长的 Markdown 中反复滚动,才能找到某个组件;刷新页面后,前端还要重新渲染整段代码,消息越多,页面越容易卡顿。 这次改造的思路,是把“聊天内容”和“项目文件”分开处理: - 聊天区域只展示生成计划、执行过程和简短说明 - AI 生成的代码直接保存到对应应用的项目目录 - 前端需要查看代码时,再通过文件接口按需读取 - 文件发生创建、修改或删除时,通过流式事件及时更新页面 这样一来,数据库不再承担保存整套项目源码的工作,聊天记录也更容易阅读。代码则按照真实项目结构保存在磁盘中,后续构建、部署和下载都可以直接复用这些文件。 现在应用页面增加了独立的代码工作区。左侧文件树展示项目目录结构,右侧显示当前选中的文件内容。点击不同文件时,前端只请求对应文件,并根据 `.vue`、`.tsx`、`.css` 等扩展名判断语言,再通过 highlight.js 进行语法高亮。 ![Vexor-Chat-Code.png](https://pic.code-nav.cn/post_picture/1828801572948791297/p9gFA8p0gJJ2FKLz.webp) > 应用生成页面可以同时查看 AI 回复、项目文件树和具体代码。 前端的文件树使用递归组件实现。目录节点可以展开和收起,文件节点会记录完整相对路径;图标则按照文件名、扩展名和目录名称匹配,让 Vue、React、TypeScript、样式文件和配置文件更容易区分: ```text vexor-frontend/src/components/AppFileTreeNode.vue vexor-frontend/src/pages/app/AppChatPage.vue ``` 后端则增加了单独的文件服务,用来扫描应用目录、组装树形结构和获取文件内容: ```text vexor-backend/src/main/java/com/xm/vexorbackend/service/AppFileService.java vexor-backend/src/main/java/com/xm/vexorbackend/service/impl/AppFileServiceImpl.java vexor-backend/src/main/java/com/xm/vexorbackend/model/vo/AppFileNodeVO.java vexor-backend/src/main/java/com/xm/vexorbackend/model/vo/AppFileContentVO.java ``` 读取文件看起来很简单,但实际还需要处理一些边界问题: - 校验当前用户是否有权查看对应应用 - 将用户传入的相对路径解析到应用目录下 - 阻止 `../` 等路径穿越,避免读取服务器上的其他文件 - 忽略 `node_modules`、构建产物等没有必要展示的目录 - 对文件不存在、读取失败等情况返回明确错误 拆分之后,聊天、代码查看、项目构建和部署各自负责自己的事情,整个生成页面也更接近一个简单的在线开发环境,而不再只是一个展示 AI 回复的聊天窗口。 --- ## 把流式回复改成更明确的生成事件 AI 生成一个完整项目通常需要几十秒,复杂项目还要等待依赖安装和构建。如果前端在这段时间里只显示一个加载动画,用户很难判断 AI 还在工作、构建正在进行,还是连接已经断开。 为了解决这个问题,我没有只把回复文本逐字推送到前端,而是在原来的 SSE 流式接口上增加了一套生成事件。后端在执行不同操作时发送对应消息,前端则根据事件类型更新聊天区域、代码内容、文件树和预览状态。 目前主要包括这些事件: ```text assistant_message AI 正在回复 tool_call AI 正在调用工具 file_start 开始生成文件 file_delta 文件内容更新 file_done 文件生成完成 file_delete 删除文件 build_status 项目构建状态 preview_ready 预览已经可用 generation_error 生成失败 done 全部完成 ``` Vue 和 React 项目主要通过文件工具创建和修改代码,工具执行时可以直接转换成对应事件。HTML 和原生多文件项目的返回格式不同,代码仍然包含在模型持续输出的 Markdown 文件块中,因此后端又增加了一个文件块流式解析器。 这个解析器不能简单地按每次收到的字符串查找代码块,因为模型返回的一个 chunk 可能只包含半行内容,甚至刚好截断在文件路径或代码块标记中间。它需要保留尚未解析完成的内容,识别完整文件块后,再依次生成 `file_start`、`file_delta` 和 `file_done` 事件,并把代码保存到磁盘。 这部分主要修改了: ```text vexor-backend/src/main/java/com/xm/vexorbackend/ai/model/message/ vexor-backend/src/main/java/com/xm/vexorbackend/core/AiCodeGeneratorFacade.java vexor-backend/src/main/java/com/xm/vexorbackend/core/handler/FileBlockStreamParser.java ``` 前端收到文件事件后,会实时更新当前代码和已生成文件列表;收到构建状态后,会提示正在构建 Vue 或 React 项目;收到 `preview_ready` 后,再刷新右侧预览。连接异常和业务异常也会单独处理,避免把正常结束误判成生成失败。 改完以后,用户不需要一直盯着加载动画,可以看到 AI 正在创建哪个文件、项目是否正在构建,以及预览什么时候可以打开。对我自己来说,这套事件也让排查生成失败变得简单了很多,因为问题发生在哪个阶段会更加清楚。 --- ## 增加 React 项目支持,也遇到了真实的构建问题 项目原本支持 HTML、原生多文件和 Vue 项目,后来我又增加了 React 项目支持。用户不需要手动选择所有情况,后端会先根据需求判断合适的生成类型,再调用对应的系统提示词、文件工具和构建器。 ![Vexor-Chat-Preview.png](https://pic.code-nav.cn/post_picture/1828801572948791297/JW7KyM84yZAqMwOw.webp) > AI 生成并构建完成的 React 2048 项目,可以直接在右侧预览。 React 支持涉及的主要目录包括: ```text vexor-backend/src/main/resources/prompt/codegen-react-project-system-prompt.txt vexor-backend/src/main/java/com/xm/vexorbackend/core/builder/ReactProjectBuilder.java vexor-backend/src/main/java/com/xm/vexorbackend/ai/tools/ ``` Vue 和 React 与普通 HTML 最大的区别,是代码生成完成并不代表可以立即预览。后端还需要在项目目录中执行 `npm install` 和 `npm run build`,确认生成了 `dist` 目录后,才能提供静态预览。 这部分在本地开发时比较顺利,但部署到 2 核 2G 的服务器后,问题很快出现了。 有一次生产环境中 AI 生成的数学函数网站 React 项目引入了体积较大的 `mathjs`。安装依赖和打包过程持续占用 CPU 与内存,不仅连续触发构建超时,还让同一台机器上的 Spring Boot、Redis 和 MySQL 得不到足够资源,最终导致整个后端响应异常。 针对这个问题,我对 React 构建流程做了几项调整: - 将安装和构建超时时间都增加到 10 分钟 - 通过 `NODE_OPTIONS=--max-old-space-size=512` 限制 Node.js 内存 - 构建完成后检查 `dist` 目录是否真的生成 - 兼容 Windows 下的 `npm.cmd` 这些调整缓解了当前服务器上的问题,但并没有彻底解决资源竞争。AI 生成项目和普通业务接口不太一样,它会执行依赖和复杂度都不完全可控的构建任务,资源消耗可能突然变大。长期来看,构建任务最好进入独立队列,并与业务服务分开部署。 --- ## 上线以后,开始处理稳定性问题 本地开发时,请求基本都来自自己,很多问题不容易暴露。项目真正部署到公网后,创建应用、生成代码、部署、下载和文生图等接口都有可能被连续调用,而这些操作背后的成本差别很大。 为此,我在后端增加了基于 Redisson 的注解式限流。接口可以按照 API、用户或 IP 三种维度设置频率,例如限制单个用户短时间内创建应用和部署的次数,也可以按照 IP 控制文生图调用量。 ```java @RateLimit( limitType = RateLimitType.USER, rate = 2, rateInterval = 600, message = "部署过于频繁,请10分钟后再试" ) ``` 使用注解的好处是限制规则能够直接写在接口旁边,不需要在每个 Controller 中重复处理。创建应用、提示词优化、代码生成、部署、下载和文生图接口目前都根据实际消耗设置了不同规则。 除了限流,最近几次修改还处理了全局异常返回、网页截图和 AI 流式响应中的稳定性问题。它们没有新页面那么直观,但上线以后,错误能否被正确捕获、连接能否正常结束、服务会不会被单个高消耗任务拖垮,反而更影响实际体验。 --- ## 从 ZeroCode 改名为 Vexor 项目最开始直接使用 ZeroCode 作为名称。随着界面和功能逐渐形成自己的样子,所以我又使用 ChatGPT 重新设计了 Logo,并把项目名称改成了 Vexor。前后端包名、页面文案、图标和相关配置也做了对应调整。 ![Vexor-LOGO.png](https://pic.code-nav.cn/post_picture/1828801572948791297/Un0UyTNSxMmCGror.webp) 不过线上域名目前仍然使用: **[zerocode.1000ye.top](https://zerocode.1000ye.top)** 域名暂时保留了项目最开始的名字,一方面避免重新调整已经部署好的地址,另一方面也算记录了它从项目一路改造过来的过程。 --- ## 最后 这次改造让我感受最深的一点是:把功能做出来和把项目真正上线,中间还有很长一段距离。 模型调用只是整个流程中的一部分。用户能不能理解当前生成进度、代码应该保存在哪里、文件接口是否安全、构建任务会不会拖垮服务器、失败时能不能给出明确提示,这些看起来不那么亮眼的小细节,最后都会直接影响项目是否真的可用。 目前 Vexor 还在继续完善,可视化编辑、构建隔离、生成质量和部署流程都有优化空间。不过相比最初只能生成和预览代码的版本,它现在已经更接近一个可以实际使用的 AI 应用生成平台。 如果你对它感兴趣,可以直接打开 [zerocode.1000ye.top](https://zerocode.1000ye.top) 体验,也欢迎查看 [GitHub 后端仓库](https://github.com/X1aoM1ngTX/zerocode-backend) 和 [GitHub 前端仓库](https://github.com/X1aoM1ngTX/zerocode-frontend)。 ![1000ye.png](https://pic.code-nav.cn/post_picture/1828801572948791297/pMGR1WTaUko3uAeT.webp)

易扣AI (Go + CloudWeGo) 企业级AI智能体项目教程 第4章:后端项目应用模块搭建

# 第4章:后端项目应用模块搭建 > 本章将讲解如何开发应用模块的基础部分和如何将第3章实现的 AI 代码生成核心功能集成到完整的后端应用模块中。 ## 知识点清单 ### 一、方案设计 #### 业务需求描述 在前面的章节,我们已经封装好了代码生成智能体。在接下来的章节,我们将进一步构建一个 AI 代码生成平台,用户可以通过自然语言描述需求提示词,AI 智能体 自动生成对应的代码文件。为了满足需求,我们需要实现以下核心功能: **核心业务功能:** | 功能模块 | 功能描述 | 技术实现 | | ------------------ | -------------------------- | ------------------- | | **应用管理** | 创建、编辑、删除、查询应用 | CRUD 操作 | | **代码生成** | 根据用户描述生成代码 | AI 模型 + Eino 框架 | | **代码部署** | 将生成的代码部署到服务器 | 静态文件服务 | | **应用展示** | 展示用户创建的应用列表 | 分页查询、排序 | #### 数据库表设计 执行以下sql语句,并且执行gorm实体结构体的生成脚本,直接生成应用表的dao文件 ##### 应用表(app) 应用表存储用户创建的应用信息,包括应用名称、封面、初始 Prompt、代码生成类型、部署信息等。 **表结构:** ```sql create table app ( id bigint auto_increment comment 'id' primary key, appName varchar(256) null comment '应用名称', cover varchar(512) null comment '应用封面', initPrompt text null comment '应用初始化的 prompt', codeGenType varchar(64) null comment '代码生成类型(枚举)', deployKey varchar(64) null comment '部署标识', deployedTime datetime null comment '部署时间', priority int default 0 not null comment '优先级', userId bigint not null comment '创建用户id', editTime datetime default CURRENT_TIMESTAMP not null comment '编辑时间', createTime datetime default CURRENT_TIMESTAMP not null comment '创建时间', updateTime datetime default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '更新时间', isDelete tinyint default 0 not null comment '是否删除', UNIQUE KEY uk_deployKey (deployKey), INDEX idx_appName (appName), INDEX idx_userId (userId) ) comment '应用' collate = utf8mb4_unicode_ci; ``` **字段说明:** | 字段名 | 类型 | 说明 | 约束 | 业务含义 | | ------------ | ------------ | ------------------- | ------------------- | ------------------------------------------------------- | | id | bigint | 应用 ID,自增主键 | PRIMARY KEY | 唯一标识一个应用 | | appName | varchar(256) | 应用名称 | NULL | 用户定义的应用名称,如"个人博客" | | cover | varchar(512) | 应用封面图片 URL | NULL | 应用展示的封面图片 | | initPrompt | text | 应用初始化的 Prompt | NULL | AI 生成代码的系统提示词,定义应用的基本功能和样式 | | codeGenType | varchar(64) | 代码生成类型 | NULL | 枚举值:html(单文件)、multi_file(多文件) | | deployKey | varchar(64) | 部署标识 | UNIQUE | 唯一的部署标识,用于生成访问链接,如 "my-blog-20240101" | | deployedTime | datetime | 部署时间 | NULL | 应用最后一次部署的时间 | | priority | int | 优先级 | NOT NULL, DEFAULT 0 | 应用展示的优先级,数值越大越靠前 | | userId | bigint | 创建用户 ID | NOT NULL | 关联用户表,标识应用的创建者 | | editTime | datetime | 最后编辑时间 | NOT NULL | 用户最后一次编辑应用的时间 | | createTime | datetime | 创建时间 | NOT NULL | 应用创建的时间 | | updateTime | datetime | 更新时间 | NOT NULL | 数据库记录更新的时间 | | isDelete | tinyint | 是否删除 | NOT NULL, DEFAULT 0 | 软删除标记,0:未删除, 1:已删除 | **索引说明:** | 索引名 | 索引类型 | 字段 | 说明 | | ------------ | -------- | --------- | ------------------------------------------ | | PRIMARY | 主键索引 | id | 主键 | | uk_deployKey | 唯一索引 | deployKey | 保证部署标识唯一性,用于生成唯一的访问链接 | | idx_appName | 普通索引 | appName | 提升按应用名称搜索的性能 | | idx_userId | 普通索引 | userId | 提升按用户 ID 查询应用的性能 | ### 二、实现应用模块基础接口 应用模块是本项目的核心模块之一,提供应用的创建、查询、更新、删除等功能。本节将按照每个接口的完整流程,从 API 层、Handler 层到 Service 层,详细讲解每个接口的实现。 #### 路由配置 **文件位置:** `internal/router/router.go` **应用模块路由分组** ```go appRoute := h.Group("/app") { // 公开接口(无需登录) appRoute.POST("/good/list/page/vo", appHandler.ListGoodApp) appRoute.GET("/get/vo", middleware.AuthMiddleware(enum.UserRole, db), appHandler.GetAppVo) // 用户接口(需要登录) appRoute.POST("/my/list/page/vo", middleware.AuthMiddleware(enum.UserRole, db), appHandler.ListMyApp) appRoute.POST("/add", middleware.AuthMiddleware(enum.UserRole, db), appHandler.AddApp) appRoute.POST("/update", middleware.AuthMiddleware(enum.UserRole, db), appHandler.UpdateApp) appRoute.POST("/delete", middleware.AuthMiddleware(enum.UserRole, db), appHandler.DeleteApp) // 管理员接口(需要管理员权限) appRoute.POST("/admin/update", middleware.AuthMiddleware(enum.AdminRole, db), appHandler.AdminUpdateApp) appRoute.POST("/admin/delete", middleware.AuthMiddleware(enum.AdminRole, db), appHandler.AdminDeleteApp) appRoute.GET("/admin/get/vo", middleware.AuthMiddleware(enum.AdminRole, db), appHandler.AdminGetAppVo) appRoute.POST("/admin/list/page/vo", middleware.AuthMiddleware(enum.AdminRole, db), appHandler.AdminListApp) } ``` #### 接口实现详解 ##### 新增应用接口 **接口路径:** `POST /app/add` **功能说明:** 用户创建新应用,填写初始 Prompt,系统自动生成应用名称和 ID。 ###### API 层 **文件位置:** `internal/api/app.go` **请求结构体:** ```go type YiKouAppAddRequest struct { InitPrompt string `json:"initPrompt"` } ``` **请求字段说明:** | 字段名 | 类型 | JSON 标签 | 说明 | 必填 | | ---------- | ------ | ---------- | ------------------------------------ | ---- | | InitPrompt | string | initPrompt | 应用初始化 Prompt,AI 根据此生成代码 | 是 | **响应结构体:** ```go type YiKouAppAddResponse response.BaseResponse[string] ``` **响应数据说明:** - 返回新创建的应用 ID(string 类型) ###### Handler 层 **文件位置:** `internal/handler/app_handler.go` **控制器结构体:** ```go type AppHandler struct { appService service.IAppService // 应用服务接口 userService service.IUserService // 用户服务接口 } func NewAppHandler( appService service.IAppService, userService service.IUserService, ) *AppHandler { return &AppHandler{ appService: appService, userService: userService, } } ``` **接口实现:** ```go // AddApp 新增应用 // @Summary 新增应用 // @Description 新增应用 // @Tags 应用模块 // @Accept json // @Produce json // @Param req body api.YiKouAppAddRequest true "新增应用请求" // @Success 200 {object} api.YiKouAppAddResponse "应用ID" // @Router /app/add [post] func (a *AppHandler) AddApp(ctx context.Context, c *app.RequestContext) { // 1. 绑定和验证请求参数 req := &api.YiKouAppAddRequest{} err := c.BindAndValidate(req) if err != nil { c.JSON(consts.StatusOK, response.NewErrorResponse[any](err)) return } // 2. 获取当前登录用户 userVo, err := a.userService.GetLoginUserVo(ctx, c) if err != nil { c.JSON(consts.StatusOK, response.NewErrorResponse[any](err)) return } // 3. 调用服务层创建应用 appId, err := a.appService.AddApp(ctx, req, userVo.ID) if err != nil { c.JSON(consts.StatusOK, response.NewErrorResponse[any](err)) return } // 4. 返回成功响应 c.JSON(consts.StatusOK, response.NewSuccessResponse[string](strconv.Itoa(int(appId)))) } ``` ###### Service 层 **文件位置:** `internal/logic/app_logic.go` **服务结构体:** ```go type AppService struct { aiCodeGenFacade *core.YiKouAiCodegenFacade // AI 代码生成门面 userService service.IUserService // 用户服务接口 db *gorm.DB // 数据库连接 } func NewAppService( aiCodeGenFacade *core.YiKouAiCodegenFacade, userService service.IUserService, db *gorm.DB, ) *AppService { return &AppService{ aiCodeGenFacade: aiCodeGenFacade, userService: userService, db: db, } } ``` **业务逻辑实现:** ```go func (s *AppService) AddApp(ctx context.Context, req *api.YiKouAppAddRequest, userId int64) (int64, error) { // 1. 参数校验 if req.InitPrompt == "" { return 0, errorutil.ParamsError.WithMessage("初始化prompt不能为空") } // 2. 生成应用名称(截取前12个字符) appName := req.InitPrompt count := 0 for i := range appName { if count >= 12 { appName = appName[:i] break } count++ } // 3. 生成应用 ID(雪花算法) appId, err := snowflake.GenerateSnowFlakeId() if err != nil { return 0, err } // 4. 构建应用实体 newApp := &model.App{ ID: appId, AppName: appName, InitPrompt: req.InitPrompt, UserID: userId, CodeGenType: string(enum.HtmlCodeGen), Priority: 0, } // 5. 保存到数据库 err = query.Use(s.db).App. Select(query.App.ID, query.App.AppName, query.App.InitPrompt, query.App.UserID, query.App.Priority, query.App.CodeGenType). Create(newApp) if err != nil { return 0, err } logger.Infof("应用创建成功,ID: %d, 类型: %s", appId, enum.HtmlCodeGen) return newApp.ID, nil } ``` ##### 更新应用接口 **接口路径:** `POST /app/update` **功能说明:** 用户更新自己的应用信息,只能更新应用名称。 ###### API 层 **请求结构体:** ```go type YiKouAppUpdateRequest struct { request.DeleteRequest AppName string `json:"appName"` } ``` **请求字段说明:** | 字段名 | 类型 | JSON 标签 | 说明 | 必填 | | ------- | ------ | --------- | ------------------------------- | ---- | | Id | int | id | 应用 ID(继承自 DeleteRequest) | 是 | | AppName | string | appName | 应用名称 | 否 | **响应结构体:** ```go type YiKouAppUpdateResponse response.BaseResponse[bool] ``` **响应数据说明:** - 返回是否更新成功(bool 类型) ###### Handler 层 **接口实现:** ```go func (a *AppHandler) UpdateApp(ctx context.Context, c *app.RequestContext) { req := &api.YiKouAppUpdateRequest{} err := c.BindAndValidate(req) if err != nil { c.JSON(consts.StatusOK, response.NewErrorResponse[any](err)) return } userVo, err := a.userService.GetLoginUserVo(ctx, c) if err != nil { c.JSON(consts.StatusOK, response.NewErrorResponse[any](err)) return } success, err := a.appService.UpdateApp(ctx, req, userVo.ID) if err != nil { c.JSON(consts.StatusOK, response.NewErrorResponse[any](err)) return } c.JSON(consts.StatusOK, response.NewSuccessResponse[bool](success)) } ``` ###### Service 层 **业务逻辑实现:** ```go func (s *AppService) UpdateApp(ctx context.Context, req *api.YiKouAppUpdateRequest, userId int64) (bool, error) { // 1. 参数校验 if req.Id == 0 { return false, errorutil.ParamsError.WithMessage("应用ID不能为空") } // 2. 查询应用 app, err := query.Use(s.db).App.Where(query.App.ID.Eq(int64(req.Id))).First() if err != nil { return false, err } // 3. 权限校验 if app.UserID != userId { return false, errorutil.ParamsError.WithMessage("无权修改该应用") } // 4. 构建更新字段 updateMap := make(map[string]interface{}) if req.AppName != "" { updateMap["appName"] = req.AppName } // 5. 执行更新 _, err = query.Use(s.db).App.Where(query.App.ID.Eq(int64(req.Id))).Updates(updateMap) if err != nil { return false, err } return true, nil } ``` ##### 删除应用接口 **接口路径:** `POST /app/delete` **功能说明:** 用户删除自己的应用,使用逻辑删除(软删除)。 ###### API 层 **请求结构体:** ```go type DeleteRequest struct { Id int `json:"id"` } ``` **请求字段说明:** | 字段名 | 类型 | JSON 标签 | 说明 | 必填 | | ------ | ---- | --------- | ------- | ---- | | Id | int | id | 应用 ID | 是 | **响应结构体:** ```go type YiKouAppDeleteResponse response.BaseResponse[bool] ``` **响应数据说明:** - 返回是否删除成功(bool 类型) ###### Handler 层 **接口实现:** ```go func (a *AppHandler) DeleteApp(ctx context.Context, c *app.RequestContext) { req := &request.DeleteRequest{} err := c.BindAndValidate(req) if err != nil { c.JSON(consts.StatusOK, response.NewErrorResponse[any](err)) return } userVo, err := a.userService.GetLoginUserVo(ctx, c) if err != nil { c.JSON(consts.StatusOK, response.NewErrorResponse[any](err)) return } success, err := a.appService.DeleteApp(ctx, int64(req.Id), userVo.ID) if err != nil { c.JSON(consts.StatusOK, response.NewErrorResponse[any](err)) return } c.JSON(consts.StatusOK, response.NewSuccessResponse[bool](success)) } ``` ###### Service 层 **业务逻辑实现:** ```go func (s *AppService) DeleteApp(ctx context.Context, id int64, userId int64) (bool, error) { // 1. 查询应用 app, err := query.Use(s.db).App.Where(query.App.ID.Eq(id)).First() if err != nil { return false, err } // 2. 权限校验 if app.UserID != userId { return false, errorutil.ParamsError.WithMessage("无权删除该应用") } // 3. 逻辑删除应用 _, err = query.Use(s.db).App.Where(query.App.ID.Eq(id)).Update(query.App.IsDelete, 1) if err != nil { return false, err } return true, nil } ``` ##### 获取应用详情接口 **接口路径:** `GET /app/get/vo` **功能说明:** 根据 ID 获取应用详情,返回应用 VO(包含用户信息)。 ###### API 层 **请求参数:** - `id`(query 参数):应用 ID **响应结构体:** ```go type YiKouAppGetVoResponse response.BaseResponse[vo.AppVo] ``` **AppVo 结构体:** ```go type AppVo struct { ID int64 `json:"id"` AppName string `json:"appName"` Cover string `json:"cover"` InitPrompt string `json:"initPrompt"` CodeGenType string `json:"codeGenType"` DeployKey string `json:"deployKey"` DeployedTime time.Time `json:"deployedTime"` Priority int32 `json:"priority"` UserID int64 `json:"userId"` User UserVo `json:"user"` CreateTime time.Time `json:"createTime"` UpdateTime time.Time `json:"updateTime"` } ``` ###### Handler 层 **接口实现:** ```go func (a *AppHandler) GetAppVo(ctx context.Context, c *app.RequestContext) { // 1. 获取查询参数 id := c.Query("id") if id == "" { c.JSON(consts.StatusOK, response.NewErrorResponse[any](errorutil.ParamsError)) return } idInt64, _ := strconv.ParseInt(id, 10, 64) // 2. 获取当前登录用户 userVo, err := a.userService.GetLoginUserVo(ctx, c) if err != nil { c.JSON(consts.StatusOK, response.NewErrorResponse[any](err)) return } // 3. 调用服务层获取应用详情 appVo, err := a.appService.GetAppVo(ctx, idInt64, userVo.ID) if err != nil { c.JSON(consts.StatusOK, response.NewErrorResponse[any](err)) return } // 4. 返回应用详情 c.JSON(consts.StatusOK, response.NewSuccessResponse[vo.AppVo](appVo)) } ``` ###### Service 层 **业务逻辑实现:** ```go func (s *AppService) GetAppVo(ctx context.Context, id int64, userId int64) (vo.AppVo, error) { // 1. 获取应用实体 app, err := s.GetApp(ctx, id, userId) if err != nil { return vo.AppVo{}, err } // 2. 获取用户信息 userVo, err := s.userService.GetUserVo(ctx, app.UserID) if err != nil { return vo.AppVo{}, err } // 3. 构建应用 VO appVo := vo.AppVo{ ID: app.ID, AppName: app.AppName, Cover: app.Cover, InitPrompt: app.InitPrompt, CodeGenType: app.CodeGenType, DeployKey: app.DeployKey, DeployedTime: app.DeployedTime, Priority: app.Priority, UserID: app.UserID, User: userVo, CreateTime: app.CreateTime, UpdateTime: app.UpdateTime, } return appVo, nil } ``` **GetApp 方法:** ```go func (s *AppService) GetApp(ctx context.Context, id int64, userId int64) (*model.App, error) { // 1. 查询应用 app, err := query.Use(s.db).App.Where(query.App.ID.Eq(id)).First() if err != nil { return nil, err } // 2. 权限校验 if app.UserID != userId { return nil, errorutil.ParamsError.WithMessage("无权查看该应用") } return app, nil } ``` ##### 我的应用列表接口 **接口路径:** `POST /app/my/list/page/vo` **功能说明:** 分页获取当前用户的应用列表,支持按应用名称模糊查询和排序。 ###### API 层 **请求结构体:** ```go type YiKouAppMyListRequest struct { request.PageRequest AppName string `json:"appName"` } ``` **PageRequest 基础结构体:** ```go type PageRequest struct { PageNum int `json:"pageNum"` PageSize int `json:"pageSize"` SortField string `json:"sortField"` SortOrder string `json:"sortOrder"` } ``` **请求字段说明:** | 字段名 | 类型 | JSON 标签 | 说明 | 必填 | | --------- | ------ | --------- | -------------------------- | ---- | | PageNum | int | pageNum | 页码,默认 1 | 否 | | PageSize | int | pageSize | 每页大小,默认 20,最大 20 | 否 | | SortField | string | sortField | 排序字段 | 否 | | SortOrder | string | sortOrder | 排序方式(asc/desc) | 否 | | AppName | string | appName | 应用名称(模糊查询) | 否 | **响应结构体:** ```go type YiKouAppMyListResponse response.BaseResponse[response.PageResponse[vo.AppVo]] ``` **PageResponse 结构体:** ```go type PageResponse[T any] struct { Records []T `json:"records"` PageNum int `json:"pageNum"` PageSize int `json:"pageSize"` TotalPage int `json:"totalPage"` TotalRow int `json:"totalRow"` OptimizeCountQuery bool `json:"optimizeCountQuery"` } ``` ###### Handler 层 **接口实现:** ```go func (a *AppHandler) ListMyApp(ctx context.Context, c *app.RequestContext) { req := &api.YiKouAppMyListRequest{} err := c.BindAndValidate(req) if err != nil { c.JSON(consts.StatusOK, response.NewErrorResponse[any](err)) return } userVo, err := a.userService.GetLoginUserVo(ctx, c) if err != nil { c.JSON(consts.StatusOK, response.NewErrorResponse[any](err)) return } pageResponse, err := a.appService.ListMyApp(ctx, req, userVo.ID) if err != nil { c.JSON(consts.StatusOK, response.NewErrorResponse[any](err)) return } c.JSON(consts.StatusOK, response.NewSuccessResponse[*response.PageResponse[vo.AppVo]](pageResponse)) } ``` ###### Service 层 **业务逻辑实现:** ```go func (s *AppService) ListMyApp(ctx context.Context, req *api.YiKouAppMyListRequest, userId int64) (*response.PageResponse[vo.AppVo], error) { // 1. 参数校验和默认值设置 if req.PageNum <= 0 { req.PageNum = 1 } if req.PageSize <= 0 { req.PageSize = 20 } if req.PageSize > 20 { req.PageSize = 20 } // 2. 构建查询条件 queryBuilder := query.Use(s.db).App.Where(query.App.IsDelete.Eq(0), query.App.UserID.Eq(userId)) if req.AppName != "" { queryBuilder = queryBuilder.Where(query.App.AppName.Like("%" + req.AppName + "%")) } // 3. 查询总数 totalCount, err := queryBuilder.Count() if err != nil { return nil, err } // 4. 计算分页信息 totalPage := int((totalCount + int64(req.PageSize) - 1) / int64(req.PageSize)) offset := (req.PageNum - 1) * req.PageSize // 5. 设置排序 if req.SortField != "" { if orderExpr, ok := query.App.GetFieldByName(req.SortField); ok { if req.SortOrder == "desc" { queryBuilder = queryBuilder.Order(orderExpr.Desc()) } else { queryBuilder = queryBuilder.Order(orderExpr) } } else { queryBuilder = queryBuilder.Order(query.App.CreateTime.Desc()) } } else { queryBuilder = queryBuilder.Order(query.App.CreateTime.Desc()) } // 6. 执行分页查询 appList, err := queryBuilder.Offset(offset).Limit(req.PageSize).Find() if err != nil { return nil, err } // 7. 转换为AppVo列表 appVoList, err := s.GetAppVoList(ctx, appList) if err != nil { return nil, err } // 8. 构建分页响应 pageResponse := &response.PageResponse[vo.AppVo]{ Records: appVoList, PageNum: req.PageNum, PageSize: req.PageSize, TotalPage: totalPage, TotalRow: int(totalCount), OptimizeCountQuery: false, } return pageResponse, nil } ``` ##### 精选应用列表接口 **接口路径:** `POST /app/good/list/page/vo` **功能说明:** 分页获取精选应用列表(priority > 0),无需登录,支持多条件查询。 ###### API 层 **请求结构体:** ```go type YiKouAppFeaturedListRequest struct { request.PageRequest AppName string `json:"appName"` CodeGenType string `json:"codeGenType"` InitPrompt string `json:"initPrompt"` Priority int32 `json:"priority"` } ``` **请求字段说明:** | 字段名 | 类型 | JSON 标签 | 说明 | 必填 | | ----------- | ------ | ----------- | -------------------------- | ---- | | PageNum | int | pageNum | 页码,默认 1 | 否 | | PageSize | int | pageSize | 每页大小,默认 20,最大 20 | 否 | | SortField | string | sortField | 排序字段 | 否 | | SortOrder | string | sortOrder | 排序方式(asc/desc) | 否 | | AppName | string | appName | 应用名称(模糊查询) | 否 | | CodeGenType | string | codeGenType | 代码生成类型 | 否 | | InitPrompt | string | initPrompt | 初始化 Prompt(模糊查询) | 否 | | Priority | int32 | priority | 优先级 | 否 | **响应结构体:** ```go type YiKouAppFeaturedListResponse response.BaseResponse[response.PageResponse[vo.AppVo]] ``` ###### Handler 层 **接口实现:** ```go func (a *AppHandler) ListGoodApp(ctx context.Context, c *app.RequestContext) { req := &api.YiKouAppFeaturedListRequest{} err := c.BindAndValidate(req) if err != nil { c.JSON(consts.StatusOK, response.NewErrorResponse[any](err)) return } pageResponse, err := a.appService.ListGoodApp(ctx, req) if err != nil { c.JSON(consts.StatusOK, response.NewErrorResponse[any](err)) return } c.JSON(consts.StatusOK, response.NewSuccessResponse[*response.PageResponse[vo.AppVo]](pageResponse)) } ``` ###### Service 层 **业务逻辑实现:** ```go func (s *AppService) ListGoodApp(ctx context.Context, req *api.YiKouAppFeaturedListRequest) (*response.PageResponse[vo.AppVo], error) { // 1. 参数校验和默认值设置 if req.PageNum <= 0 { req.PageNum = 1 } if req.PageSize <= 0 { req.PageSize = 20 } if req.PageSize > 20 { req.PageSize = 20 } // 2. 构建查询条件(精选应用:priority > 0) queryBuilder := query.Use(s.db).App.Where(query.App.IsDelete.Eq(0), query.App.Priority.Gt(0)) // 3. 添加查询条件 if req.AppName != "" { queryBuilder = queryBuilder.Where(query.App.AppName.Like("%" + req.AppName + "%")) } if req.CodeGenType != "" { queryBuilder = queryBuilder.Where(query.App.CodeGenType.Eq(req.CodeGenType)) } if req.InitPrompt != "" { queryBuilder = queryBuilder.Where(query.App.InitPrompt.Like("%" + req.InitPrompt + "%")) } if req.Priority != 0 { queryBuilder = queryBuilder.Where(query.App.Priority.Eq(req.Priority)) } // 4. 查询总数 totalCount, err := queryBuilder.Count() if err != nil { return nil, err } // 5. 计算分页信息 totalPage := int((totalCount + int64(req.PageSize) - 1) / int64(req.PageSize)) offset := (req.PageNum - 1) * req.PageSize // 6. 设置排序(默认按优先级降序、创建时间降序) if req.SortField != "" { if orderExpr, ok := query.App.GetFieldByName(req.SortField); ok { if req.SortOrder == "desc" { queryBuilder = queryBuilder.Order(orderExpr.Desc()) } else { queryBuilder = queryBuilder.Order(orderExpr) } } else { queryBuilder = queryBuilder.Order(query.App.Priority.Desc(), query.App.CreateTime.Desc()) } } else { queryBuilder = queryBuilder.Order(query.App.Priority.Desc(), query.App.CreateTime.Desc()) } // 7. 执行分页查询 appList, err := queryBuilder.Offset(offset).Limit(req.PageSize).Find() if err != nil { return nil, err } // 8. 转换为AppVo列表 appVoList, err := s.GetAppVoList(ctx, appList) if err != nil { return nil, err } // 9. 构建分页响应 pageResponse := &response.PageResponse[vo.AppVo]{ Records: appVoList, PageNum: req.PageNum, PageSize: req.PageSize, TotalPage: totalPage, TotalRow: int(totalCount), } return pageResponse, nil } ``` ##### 管理员更新应用接口 **接口路径:** `POST /app/admin/update` **功能说明:** 管理员更新应用信息,可更新应用名称、封面和优先级,无需权限校验。 ###### API 层 **请求结构体:** ```go type YiKouAppAdminUpdateRequest struct { Id string `json:"id"` AppName string `json:"appName"` Cover string `json:"cover"` Priority int32 `json:"priority"` } ``` **响应结构体:** ```go type YiKouAppAdminUpdateResponse response.BaseResponse[bool] ``` ###### Handler 层 **接口实现:** ```go func (a *AppHandler) AdminUpdateApp(ctx context.Context, c *app.RequestContext) { req := &api.YiKouAppAdminUpdateRequest{} err := c.BindAndValidate(req) if err != nil { c.JSON(consts.StatusOK, response.NewErrorResponse[any](err)) return } success, err := a.appService.AdminUpdateApp(ctx, req) if err != nil { c.JSON(consts.StatusOK, response.NewErrorResponse[any](err)) return } c.JSON(consts.StatusOK, response.NewSuccessResponse[bool](success)) } ``` ###### Service 层 **业务逻辑实现:** ```go func (s *AppService) AdminUpdateApp(ctx context.Context, req *api.YiKouAppAdminUpdateRequest) (bool, error) { // 1. 参数校验 if req.Id == "" { return false, errorutil.ParamsError.WithMessage("应用ID不能为空") } appId, err := strconv.Atoi(req.Id) if err != nil { return false, err } // 2. 查询应用 _, err = query.Use(s.db).App.Where(query.App.ID.Eq(int64(appId))).First() if err != nil { return false, err } // 3. 构建更新字段 updateMap := make(map[string]interface{}) if req.AppName != "" { updateMap["appName"] = req.AppName } if req.Cover != "" { updateMap["cover"] = req.Cover } updateMap["priority"] = req.Priority // 4. 执行更新 _, err = query.Use(s.db).App.Where(query.App.ID.Eq(int64(appId))).Updates(updateMap) if err != nil { return false, err } return true, nil } ``` ##### 管理员删除应用接口 **接口路径:** `POST /app/admin/delete` **功能说明:** 管理员删除应用,使用逻辑删除,无需权限校验。 ###### API 层 **请求结构体:** ```go type DeleteRequest struct { Id int `json:"id"` } ``` **响应结构体:** ```go type YiKouAppAdminDeleteResponse response.BaseResponse[bool] ``` ###### Handler 层 **接口实现:** ```go func (a *AppHandler) AdminDeleteApp(ctx context.Context, c *app.RequestContext) { req := &request.DeleteRequest{} err := c.BindAndValidate(req) if err != nil { c.JSON(consts.StatusOK, response.NewErrorResponse[any](err)) return } success, err := a.appService.AdminDeleteApp(ctx, int64(req.Id)) if err != nil { c.JSON(consts.StatusOK, response.NewErrorResponse[any](err)) return } c.JSON(consts.StatusOK, response.NewSuccessResponse[bool](success)) } ``` ###### Service 层 **业务逻辑实现:** ```go func (s *AppService) AdminDeleteApp(ctx context.Context, id int64) (bool, error) { // 逻辑删除应用 _, err := query.Use(s.db).App.Where(query.App.ID.Eq(id)).Update(query.App.IsDelete, 1) if err != nil { return false, err } return true, nil } ``` ##### 管理员获取应用详情接口 **接口路径:** `GET /app/admin/get/vo` **功能说明:** 管理员根据 ID 获取应用详情,无需权限校验。 ###### API 层 **请求参数:** - `id`(query 参数):应用 ID **响应结构体:** ```go type YiKouAppAdminGetResponse response.BaseResponse[vo.AppVo] ``` ###### Handler 层 **接口实现:** ```go func (a *AppHandler) AdminGetAppVo(ctx context.Context, c *app.RequestContext) { id := c.Query("id") if id == "" { c.JSON(consts.StatusOK, response.NewErrorResponse[any](errorutil.ParamsError)) return } idInt64, _ := strconv.ParseInt(id, 10, 64) appVo, err := a.appService.AdminGetAppVo(ctx, idInt64) if err != nil { c.JSON(consts.StatusOK, response.NewErrorResponse[any](err)) return } c.JSON(consts.StatusOK, response.NewSuccessResponse[vo.AppVo](appVo)) } ``` ###### Service 层 **业务逻辑实现:** ```go func (s *AppService) AdminGetAppVo(ctx context.Context, id int64) (vo.AppVo, error) { // 1. 查询应用 app, err := query.Use(s.db).App.Where(query.App.ID.Eq(id)).First() if err != nil { return vo.AppVo{}, err } // 2. 获取用户信息 userVo, err := s.userService.GetUserVo(ctx, app.UserID) if err != nil { return vo.AppVo{}, err } // 3. 构建应用 VO appVo := vo.AppVo{ ID: app.ID, AppName: app.AppName, Cover: app.Cover, InitPrompt: app.InitPrompt, CodeGenType: app.CodeGenType, DeployKey: app.DeployKey, DeployedTime: app.DeployedTime, Priority: app.Priority, UserID: app.UserID, User: userVo, CreateTime: app.CreateTime, UpdateTime: app.UpdateTime, } return appVo, nil } ``` ##### 管理员应用列表接口 **接口路径:** `POST /app/admin/list/page/vo` **功能说明:** 管理员分页获取所有应用列表,支持多条件查询,无需权限校验。 ###### API 层 **请求结构体:** ```go type YiKouAppAdminListRequest struct { request.PageRequest ID string `json:"id"` AppName string `json:"appName"` Cover string `json:"cover"` InitPrompt string `json:"initPrompt"` CodeGenType string `json:"codeGenType"` DeployKey string `json:"deployKey"` DeployedTime string `json:"deployedTime"` Priority int32 `json:"priority"` UserID int64 `json:"userId"` } ``` **响应结构体:** ```go type YiKouAppAdminListResponse response.BaseResponse[response.PageResponse[model.App]] ``` **注意:** 管理员列表返回的是 `model.App` 实体,而不是 `vo.AppVo`。 ###### Handler 层 **接口实现:** ```go func (a *AppHandler) AdminListApp(ctx context.Context, c *app.RequestContext) { req := &api.YiKouAppAdminListRequest{} err := c.BindAndValidate(req) if err != nil { c.JSON(consts.StatusOK, response.NewErrorResponse[any](err)) return } pageResponse, err := a.appService.AdminListApp(ctx, req) if err != nil { c.JSON(consts.StatusOK, response.NewErrorResponse[any](err)) return } c.JSON(consts.StatusOK, response.NewSuccessResponse[*response.PageResponse[*model.App]](pageResponse)) } ``` ###### Service 层 **业务逻辑实现:** ```go func (s *AppService) AdminListApp(ctx context.Context, req *api.YiKouAppAdminListRequest) (*response.PageResponse[*model.App], error) { // 1. 参数校验和默认值设置 if req.PageNum <= 0 { req.PageNum = 1 } if req.PageSize <= 0 { req.PageSize = 20 } if req.PageSize > 20 { req.PageSize = 20 } // 2. 构建查询条件 queryBuilder := query.Use(s.db).App.Where(query.App.IsDelete.Eq(0)) // 3. 添加查询条件 if req.ID != "" { id, _ := strconv.ParseInt(req.ID, 10, 64) queryBuilder = queryBuilder.Where(query.App.ID.Eq(id)) } if req.AppName != "" { queryBuilder = queryBuilder.Where(query.App.AppName.Like("%" + req.AppName + "%")) } if req.Cover != "" { queryBuilder = queryBuilder.Where(query.App.Cover.Like("%" + req.Cover + "%")) } if req.InitPrompt != "" { queryBuilder = queryBuilder.Where(query.App.InitPrompt.Like("%" + req.InitPrompt + "%")) } if req.CodeGenType != "" { queryBuilder = queryBuilder.Where(query.App.CodeGenType.Eq(req.CodeGenType)) } if req.DeployKey != "" { queryBuilder = queryBuilder.Where(query.App.DeployKey.Like("%" + req.DeployKey + "%")) } if req.Priority != 0 { queryBuilder = queryBuilder.Where(query.App.Priority.Eq(req.Priority)) } if req.UserID != 0 { queryBuilder = queryBuilder.Where(query.App.UserID.Eq(req.UserID)) } // 4. 查询总数 totalCount, err := queryBuilder.Count() if err != nil { return nil, err } // 5. 计算分页信息 totalPage := int((totalCount + int64(req.PageSize) - 1) / int64(req.PageSize)) offset := (req.PageNum - 1) * req.PageSize // 6. 设置排序 if req.SortField != "" { if orderExpr, ok := query.App.GetFieldByName(req.SortField); ok { if req.SortOrder == "desc" { queryBuilder = queryBuilder.Order(orderExpr.Desc()) } else { queryBuilder = queryBuilder.Order(orderExpr) } } else { queryBuilder = queryBuilder.Order(query.App.CreateTime.Desc()) } } else { queryBuilder = queryBuilder.Order(query.App.CreateTime.Desc()) } // 7. 执行分页查询 appList, err := queryBuilder.Offset(offset).Limit(req.PageSize).Find() if err != nil { return nil, err } // 8. 构建分页响应 pageResponse := &response.PageResponse[*model.App]{ Records: appList, PageNum: req.PageNum, PageSize: req.PageSize, TotalPage: totalPage, TotalRow: int(totalCount), } return pageResponse, nil } ``` ### 三、实现应用生成接口 应用生成接口是本项目的核心功能,实现了用户与应用的AI对话,实时生成代码并保存。本节我将详细讲解应用生成接口的实现,包括流式响应、代码解析、代码保存等关键流程。 #### 接口实现详解 ##### Handler 层实现 **文件位置:** `internal/handler/app_handler.go` **接口实现:** ```go // ChatToGenCode 应用聊天生成代码(流式) // @Summary 应用聊天生成代码(流式) // @Description 应用聊天生成代码(流式) // @Tags 应用模块 // @Accept json // @Produce json // @Param appId query string true "应用ID" // @Param message query string true "消息" // @Router /app/chat/gen/code [get] func (a *AppHandler) ChatToGenCode(ctx context.Context, c *app.RequestContext) { // 1. 设置 SSE 响应头 c.Header("Content-Type", "text/event-stream") c.Header("Cache-Control", "no-cache") c.Header("Connection", "keep-alive") c.Header("X-Accel-Buffering", "no") // 2. 获取请求参数 appIdStr := c.Query("appId") w := sse.NewWriter(c) lastEventID := sse.GetLastEventID(&c.Request) if appIdStr == "" { c.JSON(consts.StatusOK, response.NewErrorResponse[any](errorutil.ParamsError.WithMessage("应用ID不能为空"))) return } message := c.Query("message") if message == "" { _ = w.WriteEvent(lastEventID, "error", []byte("消息不能为空")) _ = w.WriteEvent(lastEventID, "done", []byte{1}) return } // 3. 获取当前登录用户 userVo, err := a.userService.GetLoginUserVo(ctx, c) if err != nil { _ = w.WriteEvent(lastEventID, "error", []byte(fmt.Sprintf("%v", err))) _ = w.WriteEvent(lastEventID, "done", []byte{1}) return } // 4. 转换应用ID appId, err := strconv.ParseInt(appIdStr, 10, 64) if err != nil { _ = w.WriteEvent(lastEventID, "error", []byte(fmt.Sprintf("%v", err))) _ = w.WriteEvent(lastEventID, "done", []byte{1}) return } // 5. 获取流数据 streamResp, err := a.appService.ChatToGenCode(ctx, appId, message, &userVo) if err != nil { _ = w.WriteEvent(lastEventID, "error", []byte(fmt.Sprintf("%v", err))) _ = w.WriteEvent(lastEventID, "done", []byte{1}) return } defer streamResp.Close() // 6. 流式返回数据 var aiResponseBuilder strings.Builder for { select { case <-ctx.Done(): logger.Info("连接中断") _ = w.WriteEvent(lastEventID, "done", []byte{1}) return default: } chunk, err := streamResp.Recv() if err == io.EOF || errors.Is(err, context.Canceled) { break } if err != nil { _ = w.WriteEvent(lastEventID, "error", []byte(fmt.Sprintf("%v", err))) _ = w.WriteEvent(lastEventID, "done", []byte{1}) return } aiResponseBuilder.WriteString(chunk.Content) // 7. 发送SSE事件 wrapper := &map[string]string{ "d": chunk.Content, } data, err := json.Marshal(wrapper) if err != nil { logger.Errorf("序列化数据失败: %v\n", err) continue } err = w.WriteEvent(lastEventID, "message", data) if err != nil { _ = w.WriteEvent(lastEventID, "error", []byte(fmt.Sprintf("%v", err))) _ = w.WriteEvent(lastEventID, "done", []byte{1}) return } } // 8. 发送完成事件 _ = w.WriteEvent(lastEventID, "done", []byte{1}) } ``` **以下是我将会对某些步骤进行详解,因为我自己在当初在开发这个代码生成接口时踩了不少坑,所以我现在通过我踩过的坑给你们讲解一些重点步骤** ###### 设置 SSE 响应头 **代码:** ```go c.Header("Content-Type", "text/event-stream") c.Header("Cache-Control", "no-cache") c.Header("Connection", "keep-alive") c.Header("X-Accel-Buffering", "no") ``` **详细说明:** | 响应头 | 值 | 说明 | | ----------------- | ----------------- | --------------------------------------------- | | Content-Type | text/event-stream | SSE协议要求的MIME类型,告诉浏览器这是流式事件 | | Cache-Control | no-cache | 禁止缓存,确保实时接收数据 | | Connection | keep-alive | 保持长连接,不断开TCP连接 | | X-Accel-Buffering | no | 禁用Nginx缓冲,确保数据实时传输 | ###### 获取流数据 **代码:** ```go streamResp, err := a.appService.ChatToGenCode(ctx, appId, message, &userVo) if err != nil { _ = w.WriteEvent(lastEventID, "error", []byte(fmt.Sprintf("%v", err))) _ = w.WriteEvent(lastEventID, "done", []byte{1}) return } defer streamResp.Close() ``` **流式响应说明:** - `streamResp`是 `*schema.StreamReader[*schema.Message]`类型 - 使用 `Recv()`方法接收流数据 - 使用 `Close()`方法关闭流 - 必须使用defer确保流关闭,避免资源泄漏 ###### 循环读取流式数据 **代码:** ```go var aiResponseBuilder strings.Builder for { select { case <-ctx.Done(): logger.Info("连接中断") _ = w.WriteEvent(lastEventID, "done", []byte{1}) return default: } chunk, err := streamResp.Recv() if err == io.EOF || errors.Is(err, context.Canceled) { break } if err != nil { _ = w.WriteEvent(lastEventID, "error", []byte(fmt.Sprintf("%v", err))) _ = w.WriteEvent(lastEventID, "done", []byte{1}) return } aiResponseBuilder.WriteString(chunk.Content) // ... 发送SSE事件 } ``` **详细说明:** | 操作 | 说明 | 技术点 | | ---------------- | -------------- | ------------------ | | strings.Builder | 构建完整响应 | 用于收集所有流数据 | | for循环 | 持续接收流数据 | 直到EOF或错误 | | select | 监听上下文取消 | 处理连接中断 | | Recv() | 接收流数据块 | 返回Message结构体 | | io.EOF | 流结束标志 | 正常结束 | | context.Canceled | 上下文取消 | 用户取消或超时 | ###### 发送SSE事件 **代码:** ```go wrapper := &map[string]string{ "d": chunk.Content, } data, err := json.Marshal(wrapper) if err != nil { logger.Errorf("序列化数据失败: %v\n", err) continue } err = w.WriteEvent(lastEventID, "message", data) if err != nil { _ = w.WriteEvent(lastEventID, "error", []byte(fmt.Sprintf("%v", err))) _ = w.WriteEvent(lastEventID, "done", []byte{1}) return } ``` 原生的data数据在传输数据的时候会丢失空格,影响了原本的内容格式。这里我们可以包装成json格式发送给前端,由前端再去解析json格式,这样就保证了格式的一致性了 **SSE事件格式:** ![在这里插入图片描述](https://pic.code-nav.cn/post_picture/1969049470100254722/stLOlLhTVvv10Y7L.webp) ###### 发送完成事件 **代码:** ```go _ = w.WriteEvent(lastEventID, "done", []byte{1}) ``` **完成事件说明:** - 流正常结束后发送 - 前端收到此事件后关闭SSE连接 - 数据为 `[]byte{1}`,表示成功完成 这里我也是踩过一个非常致命的坑,我在前端调试的过程中,发现每次后端结束流的时候都没有发送完成事件。后来我发现,原来是因为hertz的sse库发送事件必须得夹带数据,不然就不会发送,当场整个人都红温了 ![在这里插入图片描述](https://pic.code-nav.cn/post_picture/1969049470100254722/HPeioZ6xzs7x5DsT.webp) ##### Service 层实现 **文件位置:** `internal/logic/app_logic.go` **业务逻辑实现:** ```go func (s *AppService) ChatToGenCode(ctx context.Context, appId int64, message string, loginUser *vo.UserVo) (*schema.StreamReader[*schema.Message], error) { // 1. 校验参数 if message == "" { return nil, errorutil.ParamsError.WithMessage("消息不能为空") } if appId == 0 || appId < 0 { return nil, errorutil.ParamsError.WithMessage("应用ID不能为空") } // 2. 校验应用是否存在 app, err := query.Use(s.db).App.Where(query.App.ID.Eq(appId), query.App.IsDelete.Eq(0)).First() if err != nil { return nil, err } // 3. 校验用户是否有权限使用该应用 if app.UserID != loginUser.ID { return nil, errorutil.NotAuthError.WithMessage("无权使用该应用") } // 4. 获取代码生成类型 if enum.CodeGenTypeTextMap[enum.CodeGenTypeEnum(app.CodeGenType)] == "" { return nil, errorutil.ParamsError.WithMessage("应用代码生成类型不支持") } // 5. 调用代码生成服务 return s.aiCodeGenFacade.GenCodeStreamAndSave(ctx, message, enum.CodeGenTypeEnum(app.CodeGenType), appId) } ``` ##### 修改 AI 代码生成门面结构体 **文件位置:** `internal/core/ai_codegen_facade.go` **流式生成并保存代码方法增加appId参数:** ```go func (y *YiKouAiCodegenFacade) GenCodeStreamAndSave(ctx context.Context, userMessage string, typeStr enum.CodeGenTypeEnum, appId int64) (*schema.StreamReader[*schema.Message], error) { switch typeStr { case enum.HtmlCodeGen: streamResp, err := y.codegenService.GenerateHtmlCodeStream(ctx, userMessage) if err != nil { return nil, err } return y.processCodeStream(streamResp, typeStr, appId) case enum.MultiFileGen: streamResp, err := y.codegenService.GenerateMultiFileCodeStream(ctx, userMessage) if err != nil { return nil, err } return y.processCodeStream(streamResp, typeStr, appId) default: return nil, fmt.Errorf("不支持的代码生成类型: %s", typeStr) } } ``` **处理代码流方法也一样:** ```go func (y *YiKouAiCodegenFacade) processCodeStream(respStream *schema.StreamReader[*schema.Message], typeStr enum.CodeGenTypeEnum, appId int64) (*schema.StreamReader[*schema.Message], error) { // 1. 复制流,一个用于处理,一个返回给上游 streams := respStream.Copy(2) processingStream := streams[0] returnStream := streams[1] // 2. 在 goroutine 中处理流数据,不阻塞返回 go func() { var builder strings.Builder defer processingStream.Close() // 3. 接收完整的流数据 for { chunk, err := processingStream.Recv() if err == io.EOF { break } if err != nil { return } builder.WriteString(chunk.Content) } // 4. 解析代码 parsedResp, err := y.codeParserExecutor.ExecuteParser(builder.String(), typeStr) if err != nil { return } // 5. 保存代码(传入appId) dirPath, err := y.codeFileSaverExecutor.ExecuteSaver(parsedResp, typeStr, appId) if err != nil { return } logger.Info("代码已保存到目录: %s", dirPath) }() return returnStream, nil } ``` ##### 代码保存器实现 **文件位置:** `internal/core/saver/codefile_saver.go` **代码保存执行器的执行方法增加appId参数:** ```go func (e *CodeFileSaverExecutor) ExecuteSaver(content interface{}, saveType enum.CodeGenTypeEnum, appId int64) (string, error) { switch saveType { case enum.HtmlCodeGen: return e.htmlCodeFileSaver.saveCode(content.(*aimodel.HtmlCodeResponse), appId) case enum.MultiFileGen: return e.multiFileCodeFileSaver.saveCode(content.(*aimodel.MultiFileCodeResponse), appId) default: return "", fmt.Errorf("不支持的代码文件类型: %s", saveType) } } ``` **代码保存模板的两个方法也一样:** ```go type CodeFileSaverTemplate[T any] struct { CodeFileSaver[T] } func (d *CodeFileSaverTemplate[T]) saveCode(response T, appId int64) (string, error) { err := d.validateInput(response) if err != nil { return "", err } dirPath, err := d.buildUniqueDir(appId) if err != nil { return "", err } return dirPath, d.saveFiles(response, dirPath) } // buildUniqueDir 构建唯一的目录名 // 目录名格式: {代码生成类型}_{唯一ID} func (d *CodeFileSaverTemplate[T]) buildUniqueDir(appId int64) (string, error) { if appId == 0 { return "", fmt.Errorf("应用id不能为空") } //构建唯一目录名 fileSaveDir, err := myfile.GetCodeOutputRoot() uniqueDirName := fmt.Sprintf("%s_%s", d.getCodeType(), strconv.FormatUint(uint64(appId), 20)) dirPath := filepath.Join(fileSaveDir, uniqueDirName) // 创建目录 err = os.MkdirAll(dirPath, os.ModePerm) if err != nil { return "", err } return dirPath, nil } ``` ##### 修改测试方法增加appId **文件位置:** `internal/core/ai_codegen_facade_test.go` **流式生成测试:** ```go func TestYiKouAiCodegenFacade_GenCodeStreamAndSave(t *testing.T) { config.SetEnvFlag("local") // 解析命令行参数 initConfig := config.InitConfig() chatModel := llm.NewChatModel(initConfig) codeGenAgent := agent.NewCodeGenAgent(chatModel, enum.MultiFileGen) parserExecutor := parser.NewCodeParserExecutor() fileSaverExecutor := saver.NewCodeFileSaverExecutor() aiCodegenFacade := NewYiKouAiCodegenFacade(codeGenAgent, parserExecutor, fileSaverExecutor) // 调用流式生成方法(传入appId) resp, err := aiCodegenFacade.GenCodeStreamAndSave(context.Background(), "帮我生成一个日常记录网站", enum.MultiFileGen, 1) if err != nil { panic(err) } var builder strings.Builder for { message, err := resp.Recv() if err != nil { break } builder.WriteString(message.Content) } assert.NotNil(t, builder.String()) } ``` ##### 修改路由配置 **文件位置:** `internal/router/router.go` **增加接口声明:** ```go appRoute := h.Group("/app") { // ... 其他路由 // 需要登录的接口 appRoute.GET("/chat/gen/code", middleware.AuthMiddleware(enum.UserRole, db), appHandler.ChatToGenCode) // ... 其他路由 } ``` ##### 修改依赖注入配置 **文件位置:** `wire/wire.go` **修改服务依赖注入(记得把包引入修改成自己的包):** ```go //go:build wireinject package wire import ( "fmt" "github.com/cloudwego/hertz/pkg/app/server" "github.com/google/wire" "github.com/hertz-contrib/swagger" "gorm.io/gorm" "strconv" "yikou-ai-go-teach/config" "yikou-ai-go-teach/docs" "yikou-ai-go-teach/internal/ai" "yikou-ai-go-teach/internal/ai/agent" "yikou-ai-go-teach/internal/ai/llm" "yikou-ai-go-teach/internal/core" "yikou-ai-go-teach/internal/core/parser" "yikou-ai-go-teach/internal/core/saver" "yikou-ai-go-teach/internal/dal" "yikou-ai-go-teach/internal/handler" "yikou-ai-go-teach/internal/logic" "yikou-ai-go-teach/internal/router" "yikou-ai-go-teach/internal/service" ) // 配置依赖 var configSet = wire.NewSet( config.InitConfig, ) var llmSet = wire.NewSet(llm.NewChatModel) // 数据库依赖 var dbSet = wire.NewSet( dal.InitDB, ) // Service依赖 var serviceSet = wire.NewSet( core.NewYiKouAiCodegenFacade, logic.NewAppService, wire.Bind(new(service.IAppService), new(*logic.AppService)), logic.NewUserService, wire.Bind(new(service.IUserService), new(*logic.UserService)), agent.NewTestCodeGenAgent, wire.Bind(new(ai.IYiKouAiCodegenService), new(*agent.CodeGenAgent)), ) // Handler依赖 var handlerSet = wire.NewSet( handler.NewUserHandler, handler.NewAppHandler, ) // initServer 初始化 Web 服务器 func initServer(cfg *config.Config, userHandler *handler.UserHandler, appHandler *handler.AppHandler, db *gorm.DB) *server.Hertz { // 动态设置 Swagger 信息 docs.SwaggerInfo.Host = fmt.Sprintf("localhost:%d", cfg.Server.Port) docs.SwaggerInfo.BasePath = cfg.Server.ContextPath // 初始化swagger路径 swaggerPath := fmt.Sprintf("http://localhost:%d%s/swagger/doc.json", cfg.Server.Port, cfg.Server.ContextPath) url := swagger.URL(swaggerPath) // 创建 Hertz 服务器 h := server.Default( server.WithHostPorts(":"+strconv.Itoa(cfg.Server.Port)), server.WithBasePath(cfg.Server.ContextPath), ) // 注册路由 router.RegisterRoutes(h, url, db, userHandler, appHandler) return h } // InitializeApp 初始化所有依赖(依赖图) func InitializeApp() (*server.Hertz, error) { panic(wire.Build( initServer, configSet, dbSet, serviceSet, handlerSet, llmSet, parser.NewCodeParserExecutor, saver.NewCodeFileSaverExecutor, )) } ``` 在项目的根目录下执行wire生成命令,生成注入文件 ```bash cd ./wire wire ``` 在ide的命令台执行swagger的api文档生成命令 ```bash swag init ``` ## 四、测试sse接口 由于sse接口不能像之前一样在swagger文档上测试,所以我们直接在前端页面上测试,尽管现在没有开发完所有的后端接口,但是我们仍然能看到接口的效果。这里你们直接到我的GitHub教学仓库[https://github.com/FeiWuSama/yikou-ai-go-teach](https://github.com/FeiWuSama/yikou-ai-go-teach)克隆或者复制仓库下载前端源码就行了。获得前端源码直接进入前端源码的根目录,打开该目录的cmd窗口,输入以下前端运行命令即可:npm run dev ```bash npm run dev ``` ![在这里插入图片描述](https://pic.code-nav.cn/post_picture/1969049470100254722/QsrRv2AHbIIvo7lx.webp) ![在这里插入图片描述](https://pic.code-nav.cn/post_picture/1969049470100254722/oRbmxVrfWHpTuv9J.webp) 然后再启动后端服务,但是一定要记住后端的服务启动端口要和前端的反向代理的配置端口一致。这里我就不教大家怎样修改前端配置了,直接修改后端的配置文件保持和前端的配置文件一样就行了 ![在这里插入图片描述](https://pic.code-nav.cn/post_picture/1969049470100254722/LsckZ0dV428iGcEc.webp) 我们启动完前端和后端,直接打开浏览器在正上方访问 [http://localhost:5173/](http://localhost:5173/) 就可以测试了 我们先在右上方登录之前注册过的账号,这里记得先提前按f12打开前端开发控制台实时观察流式输出过程,然后在对话框输入提示词: ```markdown 请帮我生成一个简单的任务记录工具网站 ``` 然后点击发送按钮 ![在这里插入图片描述](https://pic.code-nav.cn/post_picture/1969049470100254722/IDfIdIdNZ9JtIyJr.webp) ![在这里插入图片描述](https://pic.code-nav.cn/post_picture/1969049470100254722/s9URiPCqZlNGVPWy.webp) ![在这里插入图片描述](https://pic.code-nav.cn/post_picture/1969049470100254722/inXLJYy1aPE3mFUc.webp) 可以看到,前端的效果是正常的,到这里我们的应用模块的已经基本完成了。在下一章,我们将会进一步拓展代码生成智能体,赋予其记忆能力,并且开发出对话记忆模块,请大家尽情期待。要是对该教程感兴趣的,可以star一下仓库 [https://github.com/FeiWuSama/yikou-ai-go](https://github.com/FeiWuSama/yikou-ai-go) 给予博主更多支持哦,谢谢各位看到这里的读者!

易扣AI (Go + CloudWeGo) 企业级AI智能体项目教程 第3章:用Eino实现AI应用生成逻辑设计

> 本章将深入讲解如何使用 AI 技术实现代码应用生成功能。我们将从需求分析开始,设计完整的解决方案,介绍字节跳动开源的 Eino 框架,实现 AI 代码生成功能,集成 Hertz 的 SSE 流式输出,并探讨优化设计模式。 ## 知识点清单 ### 一、需求分析 #### **AI 代码生成的应用场景** 本项目的核心目标是实现一个智能应用代码生成系统,支持用户通过自然语言描述需求,AI 自动生成相应的代码应用。而本章先实现基本的需求场景,封装ai为智能体,然后使ai能生成原生网页代码,并保存到本地 #### 两种代码生成逻辑设计 本项目支持两种核心的代码生成模式,满足不同场景的需求。 ##### 原生 HTML 代码生成 只生成一个html文件,将所有代码(html,css,js)全部封装到一个文件中,满足简单网页应用的生成 ##### 原生多文件代码生成 按照标准的前端项目架构,分别生成html文件、css文件和js文件 ### 二、方案设计 #### 整体架构流程设计 ![在这里插入图片描述](https://pic.code-nav.cn/post_picture/1969049470100254722/JhHvkDecQMZ4ZTg3.png) #### AI 模型选型 AI 模型的选择是项目的核心决策之一,需要综合考虑性能、成本、稳定性、合规性等多个维度。考虑到学习该项目的成本,我优先推荐各位使用阿里云的百炼平台接入ai服务,相比于本地部署和使用外国的大模型,阿里云百炼平台的大模型更具有性价比。当然,我选择阿里云的百炼平台是受限于学习成本,要是在实际的公司业务需求中,项目经理肯定会更综合的考虑使用什么模型,但我们只需要了解如何接入模型,如何将模型运用到自己的项目就行了 ##### 阿里云百炼平台详解 **平台概述:** **阿里云百炼**是基于通义大模型的一站式大模型应用开发平台,提供从模型训练、部署到应用开发的全链路服务。 ![在这里插入图片描述](https://pic.code-nav.cn/post_picture/1969049470100254722/ARFa5y7r76ItMYRO.webp) 在百炼平台的上方我们切换为全部模型 ![在这里插入图片描述](https://pic.code-nav.cn/post_picture/1969049470100254722/KWJNJYUmQR1gTMWn.webp) ![在这里插入图片描述](https://pic.code-nav.cn/post_picture/1969049470100254722/rtbIaoenSfJAgqOX.webp) 只要随便点击一个大模型,我们就能查看同系列的所有大模型的具体使用信息,例如:该大模型支持不支持function calling和结构化输出、该模型的token使用价格之类的信息 ![在这里插入图片描述](https://pic.code-nav.cn/post_picture/1969049470100254722/H3Jb9vxY4pAFsftf.webp) **核心优势:** | 优势 | 说明 | | ------------------ | ---------------------------------------- | | **性价比高** | 相比国际模型,价格更具优势 | | **易于集成** | 提供完善的SDK和API接口 | | **模型丰富** | 支持多种通义模型(通义千问、通义万相等) | 这里综合考虑,我最后选用了deepseek-v3.2作为该项目使用的大模型,大家也可以选用自己喜欢的大模型进行使用,下面我们将要修改原先占位的ai配置属性 **百炼平台配置:** ```yaml # AI服务配置 ai: chat-model: base-url: https://dashscope.aliyuncs.com/compatible-mode/v1 api-key: <你的api-key> model-name: deepseek-v3.2 memory-store: redis memory-ttl: 3600 ``` 修改 `config/config.go`的AIConfig结构体 ```go type AIConfig struct { ChatModel ChatModelConfig `yaml:"chat-model" mapstructure:"chat-model"` } type ChatModelConfig struct { BaseURL string `yaml:"base-url" mapstructure:"base-url"` APIKey string `yaml:"api-key" mapstructure:"api-key"` ModelName string `yaml:"model-name" mapstructure:"model-name"` MemoryTTL int `yaml:"memory-ttl" mapstructure:"memory-ttl"` } ``` ##### 设计提示词 提示词是影响AI生成文本效果的决定性因素,设计好一个智能体的第一步是设计一段高质量的提示词,关于如何编写提示词的诀窍我就不在这里展开讲了,详细可以参考下阿里云的标准:[https://help.aliyun.com/zh/model-studio/use-cases/prompt-engineering-guide](https://help.aliyun.com/zh/model-studio/use-cases/prompt-engineering-guide) 大家也可以直接叫ai按照规范生成一份提示词,然后在使用生成的提示词测试一下看看效果,我在下面就直接给出我自己的提示词了 ###### 1. HTML单文件代码生成提示词 **文件位置:** `prompt/codegen-html-system-prompt.txt` **提示词内容:** ```markdown 你是一位资深的 Web 前端开发专家,精通 HTML、CSS 和原生 JavaScript。你擅长构建响应式、美观且代码整洁的单页面网站。 你的任务是根据用户提供的网站描述,生成一个完整、独立的单页面网站。你需要一步步思考,并最终将所有代码整合到一个 HTML 文件中。 约束: 1. 技术栈: 只能使用 HTML、CSS 和原生 JavaScript。 2. 禁止外部依赖: 绝对不允许使用任何外部 CSS 框架、JS 库或字体库。所有功能必须用原生代码实现。 3. 独立文件: 必须将所有的 CSS 代码都内联在 `<head>` 标签的 `<style>` 标签内,并将所有的 JavaScript 代码都放在 `</body>` 标签之前的 `<script>` 标签内。最终只输出一个 `.html` 文件,不包含任何外部文件引用。 4. 响应式设计: 网站必须是响应式的,能够在桌面和移动设备上良好显示。请优先使用 Flexbox 或 Grid 进行布局。 5. 内容填充: 如果用户描述中缺少具体文本或图片,请使用有意义的占位符。例如,文本可以使用 Lorem Ipsum,图片可以使用 https://picsum.photos 的服务 (例如 `<img src="https://picsum.photos/800/600" alt="Placeholder Image">`)。 6. 代码质量: 代码必须结构清晰、有适当的注释,易于阅读和维护。 7. 交互性: 如果用户描述了交互功能 (如 Tab 切换、图片轮播、表单提交提示等),请使用原生 JavaScript 来实现。 8. 安全性: 不要包含任何服务器端代码或逻辑。所有功能都是纯客户端的。 9. 输出格式: 你的最终输出必须包含 HTML 代码块,可以在代码块之外添加解释、标题或总结性文字。格式如下: ```html ... HTML 代码 ... ... 对代码生成的解释性文字 ... 特别注意:在生成代码后,用户可能会提出修改要求并给出要修改的元素信息。 1. 你必须严格按照要求修改,不要额外修改用户要求之外的元素和内容 2. 确保始终最多输出 1 个 HTML 代码块,里面包含了完整的页面代码(而不是要修改的部分代码)。 3. 一定不能输出超过 1 个代码块,否则会导致保存错误! ``` ###### 2. 多文件代码生成提示词 **文件位置:** `prompt/codegen-multi-file-system-prompt.txt` **提示词内容:** ```markdown 你是一位资深的Web 前端开发专家,你精‌通编写结构化的 HTML、清晰的 CSS 和高效的原生JavaScript,遵循代؜码分离和模块化的最佳实践。 你的任务是根据用户提供的网站描述,创建构成一个完整单页网站所需的三个核心文件:HTML, CSS, 和 JavaScript。你需要在最终输出时,将这三部分代码分别放入三个独立的 Markdown 代码块中,并明确标注文件名。 约束: 1. 技术栈: 只能使用 HTML、CSS 和原生 JavaScript。 2. 文件分离: - index.html: 只包含网页的结构和内容。它必须在 `<head>` 中通过 `<link>` 标签引用 `style.css`,并且在 `</body>` 结束标签之前通过 `<script>` 标签引用 `script.js`。 - style.css: 包含网站所有的样式规则。 - script.js: 包含网站所有的交互逻辑。 3. 禁止外部依赖: 绝对不允许使用任何外部 CSS 框架、JS 库或字体库。所有功能必须用原生代码实现。 4. 响应式设计: 网站必须是响应式的,能够在桌面和移动设备上良好显示。请在 CSS 中使用 Flexbox 或 Grid 进行布局。 5. 内容填充: 如果用户描述中缺少具体文本或图片,请使用有意义的占位符。例如,文本可以使用 Lorem Ipsum,图片可以使用 https://picsum.photos 的服务 (例如 `<img src="https://picsum.photos/800/600" alt="Placeholder Image">`)。 6. 代码质量: 代码必须结构清晰、有适当的注释,易于阅读和维护。 7. 输出格式: 每个代码块前要注明文件名。可以在代码块之外添加解释、标题或总结性文字。格式如下: ```html ... HTML 代码 ... ```css ... CSS 代码 ... ```javascript ... JavaScript 代码 ... ... 对代码生成的解释性文字 ... 特别注意:在生成代码后,用户可能会提出修改要求并给出要修改的元素信息。 1. 你必须严格按照要求修改,不要额外修改用户要求之外的元素和内容 2. 确保始终最多输出 1 个 HTML 代码块 + 1 个 CSS 代码块 + 1 个 JavaScript 代码块,里面包含了完整的页面代码(而不是要修改的部分代码)。 3. 每种语言的代码块一定不能输出超过 1 个,否则会导致保存错误! ``` ### 三、Eino 框架介绍 #### Eino 框架概述 **Eino['aino]** (近似音: i know,希望框架能达到 "i know" 的愿景) 旨在提供基于 Go 语言的终极大模型应用开发框架。它从开源社区中的诸多优秀 LLM 应用开发框架,如 LangChain 和 LlamaIndex 等获取灵感,同时借鉴前沿研究成果与实际应用,提供了一个强调简洁性、可扩展性、可靠性与有效性,且更符合 Go 语言编程惯例的 LLM 应用开发框架。 ![在这里插入图片描述](https://pic.code-nav.cn/post_picture/1969049470100254722/p2bormIgXEznGhMZ.webp) **在这里,我也吐槽一下自己,我之前一直将'a的发音误以为是i的发言,在查完官网才知道,绝了** **官网地址:** [https://www.cloudwego.io/zh/docs/eino/](https://www.cloudwego.io/zh/docs/eino/) **GitHub 仓库:** [https://github.com/cloudwego/eino](https://github.com/cloudwego/eino) ##### Eino 提供的价值 Eino 为开发者提供以下核心价值: | 价值 | 说明 | | ------------------------------- | ------------------------------------------------------------------------------------ | | **组件抽象与实现** | 精心整理的一系列组件(component)抽象与实现,可轻松复用与组合,用于构建 LLM 应用 | | **智能体开发套件(ADK)** | 提供构建 AI 智能体的高级抽象,支持多智能体编排、人机协作中断机制以及预置的智能体模式 | | **强大的编排框架** | 为用户承担繁重的类型检查、流式处理、并发管理、切面注入、选项赋值等工作 | | **简洁的 API** | 一套精心设计、注重简洁明了的 API | | **最佳实践集合** | 以集成流程(flow)和示例(example)形式不断扩充的最佳实践集合 | | **实用工具(DevOps)** | 一套实用工具,涵盖从可视化开发与调试到在线追踪与评估的整个开发生命周期 | ##### Eino vs LangChain-Go LangChain-Go 是 LangChain 的 Go 语言实现,而 Eino 是字节跳动基于 Go 语言开发的 LLM 应用框架。两者都是优秀的 LLM 应用开发框架,但在设计理念、技术实现和适用场景上有所不同。 **1. 项目背景对比** | 对比维度 | Eino | LangChain-Go | | ------------------ | ------------------------------------------------------ | --------------------------- | | **开发团队** | 字节跳动 CloudWeGo 团队 | LangChain 社区 | | **开源时间** | 2024年 | 2023年 | | **设计理念** | 强调简洁性、可扩展性、可靠性与有效性,符合 Go 语言惯例 | Python LangChain 的 Go 移植 | | **成熟度** | 在字节跳动内部经过半年以上的实践验证 | 社区驱动,持续迭代 | | **生态支持** | CloudWeGo 生态(Hertz、Kitex 等) | LangChain 生态 | **2. 技术特性对比** | 技术特性 | Eino | LangChain-Go | | -------------------- | -------------------------------------- | ---------------------------- | | **类型安全** | ✅ 强类型,编译时类型检查 | ⚠️ 部分弱类型,运行时检查 | | **流式处理** | ✅ 原生支持,自动处理流式响应 | ✅ 支持,但需要手动处理 | | **并发管理** | ✅ 自动管理,线程安全 | ⚠️ 需要开发者手动管理 | | **编排能力** | ✅ Chain、Graph、Workflow 三种编排方式 | ✅ Chain、Graph 编排 | | **组件抽象** | ✅ 清晰的组件接口定义 | ✅ 丰富的组件实现 | | **错误处理** | ✅ 完善的错误处理和恢复机制 | ⚠️ 基础的错误处理 | | **性能优化** | ✅ 针对 Go 语言优化,高性能 | ⚠️ 性能一般 | | **代码可读性** | ✅ 符合 Go 语言惯例,易读 | ⚠️ Python 风格,可读性一般 | **3. 总结** | 总结维度 | Eino | LangChain-Go | | ------------------ | ---------------------------------- | ------------------------------- | | **核心优势** | 性能优异、符合 Go 惯例、企业级支持 | Python 风格、有原型基础 | | **主要劣势** | 相对较新,生态还在完善 | 性能一般,不够 Go 化 | | **推荐指数** | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | | **适合人群** | Go 开发者、企业项目、性能要求高 | Python 转型、快速原型、社区支持 | 截至我现在在做教程的时候,eino的仓库已经接近12k的star量了,而比eino还要早开源的langchaingo才9k左右的star量,甚至现在eino短短一年内就已经维护到0.8版本了准备今年1.0的正式发布(我记得我刚开始构建先项目的时候是0.7),而langchaingo开源了好几年才发布了14个版本,这开发社区活跃度也是没谁了awa,我相信大家肯定看出了两者目前的差距了。而且还要一个最重要的因素,就是eino官方文档同时支持英文和中文,大大降低了大部分新手程序员的入手难度! ![在这里插入图片描述](https://pic.code-nav.cn/post_picture/1969049470100254722/7qKbMP30obFbmPFQ.webp) ![在这里插入图片描述](https://pic.code-nav.cn/post_picture/1969049470100254722/dVE7hCTvPPNR2bTP.webp) #### Eino 核心概念 Eino 的核心概念围绕"组件"和"编排"展开,通过清晰的抽象和强大的编排能力,帮助开发者快速构建复杂的 LLM 应用。我们这一章先粗略地介绍下eino的核心组件**ADK Agent**,该组件是我们入手框架的第一步。 ##### ADK Agent(智能体开发套件) **定义:** ADK(Agent Development Kit)是 Eino 提供的智能体开发套件,用于构建 AI 智能体的高级抽象,支持多智能体编排、人机协作中断机制以及预置的智能体模式。 **核心价值:** | 价值 | 说明 | | ---------------------- | ------------------------------------------- | | **高级抽象** | 提供智能体级别的高级API,简化开发流程 | | **工具集成** | 自动处理工具调用、结果解析、错误处理 | | **多智能体编排** | 支持多个智能体协作完成复杂任务 | | **人机协作** | 支持中断机制,实现人在环路的交互模式 | | **预置模式** | 提供ReAct、Plan-and-Execute等常见智能体模式 | **ADK Agent 类型:** | Agent 类型 | 说明 | 适用场景 | | ----------------------------- | -------------------- | ---------------------- | | **ChatModelAgent** | 基于对话模型的智能体 | 简单对话、问答系统 | | **ReActAgent** | 推理-行动智能体 | 需要工具调用的复杂任务 | | **PlanAndExecuteAgent** | 规划-执行智能体 | 多步骤复杂任务 | | **MultiAgent** | 多智能体协作系统 | 需要多个专家协作的任务 | **1. ChatModelAgent(对话模型智能体)** 最简单的智能体,直接基于对话模型进行交互。 ```go package main import ( "context" "fmt" "github.com/cloudwego/eino/adk" "github.com/cloudwego/eino/components/model/openai" ) func main() { ctx := context.Background() // 创建 ChatModel model, _ := openai.NewChatModel(ctx, &openai.ChatModelConfig{ Model: "gpt-4", }) // 创建 ChatModelAgent agent := adk.NewChatModelAgent(model) // 执行对话 result, _ := agent.Invoke(ctx, []*schema.Message{ schema.UserMessage("What is the capital of France?"), }) fmt.Println(result.Content) } ``` **2. ReActAgent(推理-行动智能体)** ReAct(Reasoning and Acting)是一种经典的智能体模式,通过"思考-行动-观察"的循环来完成任务。 **ReActAgent 示例:** ```go package main import ( "context" "fmt" "github.com/cloudwego/eino/adk" "github.com/cloudwego/eino/components/model/openai" "github.com/cloudwego/eino/components/tool" ) func main() { ctx := context.Background() // 创建 ChatModel model, _ := openai.NewChatModel(ctx, &openai.ChatModelConfig{ Model: "gpt-4", }) // 定义工具 weatherTool := &tool.Tool{ Name: "get_weather", Description: "Get weather information for a city", Execute: func(ctx context.Context, city string) (string, error) { return fmt.Sprintf("Weather in %s: Sunny, 25°C", city), nil }, } searchTool := &tool.Tool{ Name: "search", Description: "Search for information on the internet", Execute: func(ctx context.Context, query string) (string, error) { return fmt.Sprintf("Search results for: %s", query), nil }, } // 创建 ReActAgent agent := adk.NewReActAgent(model, []tool.Tool{weatherTool, searchTool}) // 执行任务 result, _ := agent.Invoke(ctx, "What's the weather in Beijing?") fmt.Println(result.Content) } ``` **3. PlanAndExecuteAgent(规划-执行智能体)** **PlanAndExecuteAgent 示例:** ```go package main import ( "context" "fmt" "github.com/cloudwego/eino/adk" "github.com/cloudwego/eino/components/model/openai" ) func main() { ctx := context.Background() // 创建 ChatModel model, _ := openai.NewChatModel(ctx, &openai.ChatModelConfig{ Model: "gpt-4", }) // 创建 PlanAndExecuteAgent agent := adk.NewPlanAndExecuteAgent(model) // 执行复杂任务 result, _ := agent.Invoke(ctx, "Research the history of AI and write a summary") fmt.Println(result.Content) } ``` **4. MultiAgent(多智能体协作)** **MultiAgent 示例:** ```go package main import ( "context" "fmt" "github.com/cloudwego/eino/adk" "github.com/cloudwego/eino/components/model/openai" ) func main() { ctx := context.Background() // 创建 ChatModel model, _ := openai.NewChatModel(ctx, &openai.ChatModelConfig{ Model: "gpt-4", }) // 创建多个专家智能体 researchAgent := adk.NewChatModelAgent(model, adk.WithSystemPrompt("You are a research expert.")) writerAgent := adk.NewChatModelAgent(model, adk.WithSystemPrompt("You are a writing expert.")) reviewerAgent := adk.NewChatModelAgent(model, adk.WithSystemPrompt("You are a review expert.")) // 创建 MultiAgent multiAgent := adk.NewMultiAgent( adk.WithAgents(map[string]adk.Agent{ "researcher": researchAgent, "writer": writerAgent, "reviewer": reviewerAgent, }), adk.WithRouter(func(ctx context.Context, task string) string { // 根据任务内容路由到合适的智能体 if strings.Contains(task, "research") { return "researcher" } if strings.Contains(task, "write") { return "writer" } return "reviewer" }), ) // 执行任务 result, _ := multiAgent.Invoke(ctx, "Research AI history and write a summary") fmt.Println(result.Content) } ``` **5. 人机协作(Human-in-the-Loop)** 支持在智能体执行过程中插入人工干预。 ```go package main import ( "context" "fmt" "github.com/cloudwego/eino/adk" "github.com/cloudwego/eino/components/model/openai" ) func main() { ctx := context.Background() // 创建 ChatModel model, _ := openai.NewChatModel(ctx, &openai.ChatModelConfig{ Model: "gpt-4", }) // 创建带人机协作的 Agent agent := adk.NewReActAgent(model, tools, adk.WithHumanInTheLoop(true), adk.WithInterruptPoint(func(ctx context.Context, state *adk.AgentState) bool { // 定义中断点:在执行重要操作前暂停 return state.CurrentStep == "critical_operation" }), ) // 执行任务 stream, _ := agent.Stream(ctx, "Perform critical operation") for { event, err := stream.Recv() if err == io.EOF { break } // 处理中断事件 if event.Type == adk.EventTypeInterrupt { // 等待人工输入 humanInput := getHumanInput() // 恢复执行 stream.Resume(ctx, humanInput) } fmt.Println(event.Content) } } ``` 大家也可以直接去查看官方文档更深入的学习,毕竟官方文档往往是一个人了解这个框架的入口。哪怕后面框架有较大的改动或者增加了什么新特性,大家也可以去官方文档那里直接了解详情。在官网的核心模块也有ADK Agent有更多的特性介绍,我推荐大家可以直接在官网入手。接下来,我们将要正式进入代码教程,开发项目的第一个智能体 ![在这里插入图片描述](https://pic.code-nav.cn/post_picture/1969049470100254722/dnBNjvxTeThpr2Vd.webp) ### 四、实现 AI 代码应用生成 #### 接入大模型 我们直接到百炼平台创建一个API KEY用于接入大模型 ![在这里插入图片描述](https://pic.code-nav.cn/post_picture/1969049470100254722/hFl8CW5RXQNpB2hG.webp) 然后我们需要下载eino的第三方库,在ide的终端输入以下命令 ```bash go get github.com/cloudwego/eino@v0.8.2 go get github.com/cloudwego/eino-ext/components/model/openai@v0.1.8 ``` #### 智能体封装 ##### **定义大模型配置** 在 `internal`包下新建 `/ai/llm/chat_model.go` ```go package llm import ( "context" "github.com/cloudwego/eino-ext/components/model/openai" "yikou-ai-go-teach/config" ) type ChatModelWrapper struct { *openai.ChatModel ModelName string } func NewChatModel(cfg *config.Config) *ChatModelWrapper { ctx := context.Background() modelName := cfg.AI.ChatModel.ModelName chatModel, err := openai.NewChatModel(ctx, &openai.ChatModelConfig{ BaseURL: cfg.AI.ChatModel.BaseURL, Model: modelName, APIKey: cfg.AI.ChatModel.APIKey, }) if err != nil { panic(err) } return &ChatModelWrapper{ ChatModel: chatModel, ModelName: modelName, } } func (w *ChatModelWrapper) GetChatModel() *openai.ChatModel { return w.ChatModel } func (w *ChatModelWrapper) GetModelName() string { return w.ModelName } ``` 这里也许有小伙伴会疑惑封装一个ChatModelWrapper结构体增加一个ModelName属性,这个 ModelName是用于后面的增加可观测性章节的,由于原本eino提供的ChatModel结构体没有ModelName的方法,我只好自己在原本的基础上再包装了 ##### 声明AI服务接口 在 `internal`包下新建 `/ai/ai_service.go`,定义ai服务接口是为了提供智能体具体的功能声明,该接口也遵守了go语言的接口声明实现规范,普遍使用于正常业务的代码设计中 ```go type IYiKouAiCodegenService interface { GenerateHtmlCode(ctx context.Context, userMessage string) (*schema.Message, error) GenerateMultiFileCode(ctx context.Context, userMessage string) (*schema.Message, error) } ``` ##### 封装智能体实现功能 智能体的封装实现是整个AI代码生成系统的核心部分,通过合理的封装设计,实现了代码的复用和扩展性。下面详细介绍各个文件的功能和实现细节。 ###### 代码生成类型枚举 (`pkg/enum/code_gentype.go`) **文件作用:** 定义代码生成的类型枚举,用于区分不同的代码生成模式。 **完整代码:** ```go package enum type CodeGenTypeEnum string const ( HtmlCodeGen CodeGenTypeEnum = "html" MultiFileGen CodeGenTypeEnum = "multi_file" VueCodeGen CodeGenTypeEnum = "vue_project" ) var CodeGenTypeTextMap = map[CodeGenTypeEnum]string{ HtmlCodeGen: "原生 HTML 模式", MultiFileGen: "原生多文件模式", VueCodeGen: "Vue工厂模式", } ``` ###### 修改文件路径工具类 (`pkg/myfile/path.go`) 增加获取代码保存路径的方法 **完整代码:** ```go func GetCodeOutputRoot() (string, error) { projectRoot, err := GetProjectRoot() if err != nil { return "", fmt.Errorf("获取项目根目录失败: %w", err) } return filepath.Join(projectRoot, "tmp/code_output"), nil } ``` ###### 提示词管理 (`internal/ai/myprompt/my_prompt.go`) **文件作用:** 加载和管理系统提示词,为不同的代码生成模式提供对应的提示词模板。 **完整代码:** ```go package myprompt import ( "os" "path/filepath" "sync" "yikou-ai-go-teach/pkg/myfile" "github.com/cloudwego/eino/components/prompt" "github.com/cloudwego/eino/schema" ) var ( htmlPrompt string multiFilePrompt string promptOnce sync.Once ) func loadPromptFile(fileName string) (string, error) { projectRoot, err := myfile.GetProjectRoot() if err != nil { return "", err } filePath := filepath.Join(projectRoot, "prompt", fileName) data, err := os.ReadFile(filePath) if err != nil { return "", err } return string(data), nil } func LoadPrompts() error { var err error promptOnce.Do(func() { htmlPrompt, err = loadPromptFile("codegen-html-system-prompt.txt") if err != nil { panic(err) } multiFilePrompt, err = loadPromptFile("codegen-multi-file-system-prompt.txt") if err != nil { panic(err) } if err != nil { panic(err) } }) return err } func GetHtmlPrompt() string { return htmlPrompt } func GetMultiFilePrompt() string { return multiFilePrompt } func NewMultiFileChatTemplate() (prompt.ChatTemplate, error) { return newChatTemplate(GetMultiFilePrompt()), nil } func NewHtmlChatTemplate() (prompt.ChatTemplate, error) { return newChatTemplate(GetHtmlPrompt()), nil } func newChatTemplate(systemPrompt string) prompt.ChatTemplate { ctp := prompt.FromMessages(schema.GoTemplate, []schema.MessagesTemplate{ schema.SystemMessage(systemPrompt), schema.MessagesPlaceholder("history", false), schema.UserMessage("{{.content}}"), }...) return ctp } ``` ###### 基础智能体封装 (`internal/ai/agent/base_agent.go`) **文件作用:** 提供智能体的基础封装,包含通用的智能体创建和执行方法,作为其他智能体的基类。 **完整代码:** ```go package agent import ( "context" "errors" "fmt" "github.com/bytedance/gopkg/util/logger" "github.com/cloudwego/eino-ext/components/model/openai" "github.com/cloudwego/eino/adk" "github.com/cloudwego/eino/components/prompt" "github.com/cloudwego/eino/components/tool" "github.com/cloudwego/eino/compose" "github.com/cloudwego/eino/schema" ) type ChatModelWrapperAdaptor interface { GetChatModel() *openai.ChatModel GetModelName() string } type BaseAgent struct { model *openai.ChatModel modelName string } func NewBaseAgent(chatModel ChatModelWrapperAdaptor) *BaseAgent { return &BaseAgent{ model: chatModel.GetChatModel(), modelName: chatModel.GetModelName(), } } func (a *BaseAgent) GetModel() *openai.ChatModel { return a.model } func (a *BaseAgent) NewAdkAgent(name, description, instruction string, tools []tool.BaseTool) *adk.ChatModelAgent { ctx := context.Background() config := &adk.ChatModelAgentConfig{ Name: name, Description: description, Instruction: instruction, Model: a.model, MaxIterations: 50, ModelRetryConfig: &adk.ModelRetryConfig{ MaxRetries: 3, IsRetryAble: func(ctx context.Context, err error) bool { if errors.Is(err, context.Canceled) { return false } return true }, }, } agent, err := adk.NewChatModelAgent(ctx, config) if err != nil { logger.Errorf("创建Agent失败: %v", err) return nil } return agent } func (a *BaseAgent) Generate(ctx context.Context, userMessage string, chatTemplate prompt.ChatTemplate, adkAgent *adk.ChatModelAgent) (*schema.Message, error) { format, err := chatTemplate.Format(ctx, map[string]any{ "content": userMessage, }) if err != nil { return nil, err } runner := adk.NewRunner(ctx, adk.RunnerConfig{ Agent: adkAgent, EnableStreaming: false, }) iter := runner.Run(ctx, format) var resultMsg *schema.Message for { event, ok := iter.Next() if !ok { break } if event.Err != nil { return nil, event.Err } if event.Output != nil && event.Output.MessageOutput != nil { msg, err := event.Output.MessageOutput.GetMessage() if err != nil { return nil, err } resultMsg = msg } } return resultMsg, nil } ``` ###### 代码生成智能体 (`internal/ai/agent/codegen_agent.go`) **文件作用:** 继承基础智能体,实现具体的代码生成功能,支持HTML和多文件两种生成模式,并使用结构化输出确保返回格式的稳定性。 **完整代码:** ```go package agent import ( "context" "encoding/json" "yikou-ai-go-teach/internal/ai/aimodel" "yikou-ai-go-teach/internal/ai/myprompt" "yikou-ai-go-teach/pkg/enum" "github.com/bytedance/gopkg/util/logger" "github.com/cloudwego/eino/adk" ) func NewCodeGenAgent(chatModel ChatModelWrapperAdaptor, codeGenType enum.CodeGenTypeEnum) *CodeGenAgent { baseAgent := NewBaseAgent(chatModel) return &CodeGenAgent{ BaseAgent: baseAgent, agentType: codeGenType, } } type CodeGenAgent struct { *BaseAgent agentType enum.CodeGenTypeEnum } func (a *CodeGenAgent) getAdkAgent() *adk.ChatModelAgent { switch a.agentType { case enum.HtmlCodeGen: return a.newHtmlFileCodeGenAgent() case enum.MultiFileGen: return a.newMultiFileCodeGenAgent() default: return nil } } func (a *CodeGenAgent) GenerateHtmlCode(ctx context.Context, userMessage string) (*aimodel.HtmlCodeResponse, error) { chatTemplate, err := myprompt.NewHtmlChatTemplate() if err != nil { return nil, err } adkAgent := a.getAdkAgent() message, err := a.Generate(ctx, userMessage+ `You must answer strictly in the following JSON format: { "htmlCode": "your html code here", "description": "description of the code" } IMPORTANT: You must answer ONLY with a valid JSON object, no markdown, no code blocks, no backticks. `, chatTemplate, adkAgent) if err != nil { return nil, err } var result aimodel.HtmlCodeResponse err = json.Unmarshal([]byte(message.Content), &result) if err != nil { return nil, err } return &result, nil } func (a *CodeGenAgent) GenerateMultiFileCode(ctx context.Context, userMessage string) (*aimodel.MultiFileCodeResponse, error) { chatTemplate, err := myprompt.NewMultiFileChatTemplate() if err != nil { return nil, err } adkAgent := a.getAdkAgent() message, err := a.Generate(ctx, userMessage+ `You must answer strictly in the following JSON format: { "htmlCode": "your html code here", "description": "description of the code", "cssCode": "your css code here", "jsCode": "your javascript code here" } IMPORTANT: You must answer ONLY with a valid JSON object, no markdown, no code blocks, no backticks. `, chatTemplate, adkAgent) if err != nil { return nil, err } var result aimodel.MultiFileCodeResponse err = json.Unmarshal([]byte(message.Content), &result) if err != nil { return nil, err } return &result, nil } func (a *CodeGenAgent) newMultiFileCodeGenAgent() *adk.ChatModelAgent { if err := myprompt.LoadPrompts(); err != nil { logger.Errorf("加载prompts失败: %v", err) return nil } return a.NewAdkAgent( "AI 代码生成助手", "具有强大的代码生成能力", myprompt.GetMultiFilePrompt(), ) } func (a *CodeGenAgent) newHtmlFileCodeGenAgent() *adk.ChatModelAgent { if err := myprompt.LoadPrompts(); err != nil { logger.Errorf("加载prompts失败: %v", err) return nil } return a.NewAdkAgent( "AI 代码生成助手", "具有强大的代码生成能力", myprompt.GetHtmlPrompt(), ) } ``` ###### 单元测试实现 (`internal/ai/agent/codegen_agent_test.go`) **文件作用:** 为代码生成智能体提供单元测试,验证HTML和多文件代码生成功能的正确性。 先修改 `config/config.go`,支持测试方法传递读取配置参数 ```go var envFlag string func SetEnvFlag(flag string) { envFlag = flag } // InitConfig 初始化配置 // env 参数用于指定配置文件后缀,如 "local" 会读取 config-local.yaml func InitConfig() *Config { if envFlag == "" { // 解析命令行参数 env := flag.String("env", "", "运行环境,如 local, dev, test, prod") flag.Parse() envFlag = *env } // 获取项目根路径 rootPath, err := GetProjectRootPath() if err != nil { panic(fmt.Errorf("获取项目根路径失败: %w", err)) } // 拼接配置文件目录路径 configPath := filepath.Join(rootPath, "config") // 确定配置文件名称 configName := "config" if envFlag != "" { configName = fmt.Sprintf("config-%s", envFlag) } // 设置配置文件名和路径 viper.SetConfigName(configName) // 配置文件名称 viper.SetConfigType("yml") // 配置文件类型 viper.AddConfigPath(configPath) // 配置文件路径 // 读取环境变量 viper.AutomaticEnv() // 读取配置文件 if err := viper.ReadInConfig(); err != nil { panic(fmt.Errorf("读取配置文件失败: %w", err)) } logger.Infof("配置文件路径: %s\n", viper.ConfigFileUsed()) // 解析配置到结构体 cfg := &Config{} if err := viper.Unmarshal(cfg); err != nil { panic(fmt.Errorf("解析配置失败: %w", err)) } return cfg } ``` **下面是 `codegen_agent_test.go`的完整代码:** ```go package agent import ( "context" "github.com/cloudwego/hertz/pkg/common/test/assert" "testing" "yikou-ai-go-teach/config" "yikou-ai-go-teach/internal/ai/llm" "yikou-ai-go-teach/pkg/enum" ) func TestCodeGenAgent_GenerateHtmlCode(t *testing.T) { config.SetEnvFlag("local") // 解析命令行参数 initConfig := config.InitConfig() chatModel := llm.NewChatModel(initConfig) codeGenAgent := NewCodeGenAgent(chatModel, enum.HtmlCodeGen) code, err := codeGenAgent.GenerateHtmlCode(context.Background(), "做个mysql学习知识图") if err != nil { return } assert.NotNil(t, code) } func TestCodeGenAgent_GenerateMultiFileCode(t *testing.T) { initConfig := config.InitConfig() chatModel := llm.NewChatModel(initConfig) codeGenAgent := NewCodeGenAgent(chatModel, enum.MultiFileGen) code, err := codeGenAgent.GenerateMultiFileCode(context.Background(), "做个留言版") if err != nil { return } assert.NotNil(t, code) } ``` **Debug测试结果示例:** ![在这里插入图片描述](https://pic.code-nav.cn/post_picture/1969049470100254722/bSFlWrcZYrvWLQW4.webp) ###### **结构化输出设计:** 结构化输出是确保AI返回数据格式稳定性的关键技术,通过JSON Schema约束AI的输出格式,在eino中实现结构化输出的途径就是在提示词拼接json输出格式的限制。 由于百炼的deepseek模型不支持结构化输出,大家可以修改yml配置文件更换千问进行测试,这一节其实不影响后面的步骤,只是给大家讲解一下这个功能特点,现在很多智能体都用到结构化输出这个功能。 ![在这里插入图片描述](https://pic.code-nav.cn/post_picture/1969049470100254722/bv67ps9UNRtXF9vc.webp) ![在这里插入图片描述](https://pic.code-nav.cn/post_picture/1969049470100254722/znfcANNinSsBtaKi.webp) ![在这里插入图片描述](https://pic.code-nav.cn/post_picture/1969049470100254722/t6SPbWH2dzXy3qQ4.webp) **结构化输出模型 (`internal/ai/aimodel/code_result.go`)** **文件作用:** 定义代码生成结果的强类型结构体,用于JSON解析和类型安全的数据传递。 **完整代码:** ```go package aimodel type HtmlCodeResponse struct { HtmlCode string `json:"htmlCode"` Description string `json:"description"` } type MultiFileCodeResponse struct { HtmlCodeResponse JsCode string `json:"jsCode"` CssCode string `json:"cssCode"` } ``` **修改AIService和代码生成智能体** 修改AIService两个方法的返回值为结构化输出模型 ```go type IYiKouAiCodegenService interface { GenerateHtmlCode(ctx context.Context, userMessage string) (*aimodel.HtmlCodeResponse, error) GenerateMultiFileCode(ctx context.Context, userMessage string) (*aimodel.MultiFileCodeResponse, error) } ``` 修改代码生成智能体增加提示词拼接json输出格式的限制,以及增加解析结构化输出结结果的逻辑 ```go func (a *CodeGenAgent) GenerateHtmlCode(ctx context.Context, userMessage string) (*aimodel.HtmlCodeResponse, error) { chatTemplate, err := myprompt.NewHtmlChatTemplate() if err != nil { return nil, err } adkAgent := a.getAdkAgent() message, err := a.Generate(ctx, userMessage+ `You must answer strictly in the following JSON format: { "htmlCode": "your html code here", "description": "description of the code" } IMPORTANT: You must answer ONLY with a valid JSON object, no markdown, no code blocks, no backticks. `, chatTemplate, adkAgent) if err != nil { return nil, err } var result aimodel.HtmlCodeResponse err = json.Unmarshal([]byte(message.Content), &result) if err != nil { return nil, err } return &result, nil } func (a *CodeGenAgent) GenerateMultiFileCode(ctx context.Context, userMessage string) (*aimodel.MultiFileCodeResponse, error) { chatTemplate, err := myprompt.NewMultiFileChatTemplate() if err != nil { return nil, err } adkAgent := a.getAdkAgent() message, err := a.Generate(ctx, userMessage+ `You must answer strictly in the following JSON format: { "htmlCode": "your html code here", "description": "description of the code", "cssCode": "your css code here", "jsCode": "your javascript code here" } IMPORTANT: You must answer ONLY with a valid JSON object, no markdown, no code blocks, no backticks. `, chatTemplate, adkAgent) if err != nil { return nil, err } var result aimodel.MultiFileCodeResponse err = json.Unmarshal([]byte(message.Content), &result) if err != nil { return nil, err } return &result, nil } ``` **重新debug单元测试** ![在这里插入图片描述](https://pic.code-nav.cn/post_picture/1969049470100254722/myriq17tRk4wbkCD.webp) 可以看到成功返回结构化输出模型了 ##### 保存代码文件实现 代码生成后需要将生成的代码保存到文件系统中,这里使用了门面模式(Facade Pattern)来统一管理代码生成和保存的流程。 ###### **什么是门面模式(Facade Pattern)?** 门面模式是一种结构型设计模式,它为复杂的子系统提供一个统一的、简化的接口。门面模式通过定义一个高层接口,使得子系统更容易使用。 **门面模式的优势:** **降低复杂度**: - 隐藏子系统的复杂性 - 客户端无需了解内部实现细节 - 减少学习成本和使用难度 **解耦客户端**: - 客户端只依赖门面接口 - 子系统变化不影响客户端 - 提高系统的灵活性和可维护性 ![在这里插入图片描述](https://pic.code-nav.cn/post_picture/1969049470100254722/cbmq53FBLsQZWvKB.png) ###### 门面模式实现 (`internal/core/ai_codegen_facade.go`) **文件作用:** 使用门面模式统一管理代码生成和保存的流程,对外提供简单的接口,隐藏内部复杂性。 **完整代码:** ```go package core import ( "context" "fmt" "yikou-ai-go-teach/internal/ai" "yikou-ai-go-teach/internal/core/saver" "yikou-ai-go-teach/pkg/enum" "github.com/bytedance/gopkg/util/logger" ) type YiKouAiCodegenFacade struct { codegenService ai.IYiKouAiCodegenService } func NewYiKouAiCodegenFacade(codegenService ai.IYiKouAiCodegenService) *YiKouAiCodegenFacade { return &YiKouAiCodegenFacade{ codegenService: codegenService, } } func (y *YiKouAiCodegenFacade) GenHtmlCodeAndSave(ctx context.Context, userMessage string) error { resp, err := y.codegenService.GenerateHtmlCode(ctx, userMessage) if err != nil { return err } dirPath, err := saver.SaveHtmlCode(*resp) if err != nil { return err } logger.Info("HTML代码已保存到目录: %s", dirPath) return nil } func (y *YiKouAiCodegenFacade) GenMultiFileCodeAndSave(ctx context.Context, userMessage string) error { resp, err := y.codegenService.GenerateMultiFileCode(ctx, userMessage) if err != nil { return err } dirPath, err := saver.SaveMultiFileCode(*resp) if err != nil { return err } logger.Info("多文件代码已保存到目录: %s", dirPath) return nil } func (y *YiKouAiCodegenFacade) GenCodeAndSave(ctx context.Context, userMessage string, typeStr enum.CodeGenTypeEnum) error { switch typeStr { case enum.HtmlCodeGen: return y.GenHtmlCodeAndSave(ctx, userMessage) case enum.MultiFileGen: return y.GenMultiFileCodeAndSave(ctx, userMessage) default: return fmt.Errorf("不支持的代码生成类型: %s", typeStr) } } ``` ###### 文件保存器 (`internal/core/saver/codefile_saver.go`) **文件作用:** 负责将生成的代码内容保存到文件系统,使用雪花算法生成唯一目录名,避免文件冲突。 **完整代码:** ```go package saver import ( "fmt" "github.com/sony/sonyflake" "os" "path/filepath" "strconv" "yikou-ai-go-teach/internal/ai/aimodel" "yikou-ai-go-teach/pkg/enum" "yikou-ai-go-teach/pkg/myfile" ) // buildUniqueDir 构建唯一的目录名 // 目录名格式: {代码生成类型}_{唯一ID} func buildUniqueDir(typeStr enum.CodeGenTypeEnum) (string, error) { // 生成雪花id var sf = sonyflake.NewSonyflake(sonyflake.Settings{ MachineID: func() (uint16, error) { return 1, nil }, }) id, err := sf.NextID() if err != nil { return "", err } // 构建唯一目录名 uniqueDirName := fmt.Sprintf("%s_%s", typeStr, strconv.FormatUint(id, 20)) fileSaveDir, err := myfile.GetCodeOutputRoot() dirPath := filepath.Join(fileSaveDir, uniqueDirName) // 创建目录 err = os.MkdirAll(dirPath, os.ModePerm) if err != nil { return "", err } return dirPath, nil } // writeToFile 将内容写入文件并保存 func writeToFile(dirPath string, fileName string, content string) error { filePath := filepath.Join(dirPath, fileName) err := os.WriteFile(filePath, []byte(content), os.ModePerm) if err != nil { return err } return nil } // SaveHtmlCode 保存 HTML 代码文件 func SaveHtmlCode(response aimodel.HtmlCodeResponse) (string, error) { dirPath, err := buildUniqueDir(enum.HtmlCodeGen) if err != nil { return "", err } fileName := "index.html" return dirPath, writeToFile(dirPath, fileName, response.HtmlCode) } // SaveMultiFileCode 保存多文件代码文件 func SaveMultiFileCode(response aimodel.MultiFileCodeResponse) (string, error) { dirPath, err := buildUniqueDir(enum.MultiFileGen) if err != nil { return "", err } // 保存 HTML 文件 err = writeToFile(dirPath, "index.html", response.HtmlCode) if err != nil { return "", err } // 保存 JS 文件 err = writeToFile(dirPath, "script.js", response.JsCode) if err != nil { return "", err } // 保存 CSS 文件 err = writeToFile(dirPath, "style.css", response.CssCode) if err != nil { return "", err } return dirPath, nil } ``` ###### 门面模式测试 (`internal/core/ai_codegen_facade_test.go`) **文件作用:** 测试门面模式的完整功能,验证代码生成和保存的端到端流程。 **完整代码:** ```go package core import ( "context" "testing" "yikou-ai-go-teach/config" "yikou-ai-go-teach/internal/ai/agent" "yikou-ai-go-teach/internal/ai/llm" "yikou-ai-go-teach/pkg/enum" ) func TestYiKouAiCodegenFacade_GenCodeAndSave(t *testing.T) { config.SetEnvFlag("local") // 解析命令行参数 initConfig := config.InitConfig() chatModel := llm.NewChatModel(initConfig) codeGenAgent := agent.NewCodeGenAgent(chatModel, enum.MultiFileGen) aiCodegenFacade := NewYiKouAiCodegenFacade(codeGenAgent) err := aiCodegenFacade.GenCodeAndSave(context.Background(), "帮我生成一个日常记录网站", enum.MultiFileGen) if err != nil { panic(err) } } ``` 运行测试方法,我们可以在项目根路径下找到tmp文件夹 ![在这里插入图片描述](https://pic.code-nav.cn/post_picture/1969049470100254722/ooHNsL64v3RNKfIT.png) 点击index.html文件,然后在ide的右上方可以看到在浏览器打开文件,点击打开图标后,我们就能看到生成的网站效果了 ![在这里插入图片描述](https://pic.code-nav.cn/post_picture/1969049470100254722/y0zb2PYpD99XcU2w.webp) ![在这里插入图片描述](https://pic.code-nav.cn/post_picture/1969049470100254722/Tc5T83kWoEL7oeCq.webp) ### 五、使用 Hertz SSE 流式输出扩展库 #### 什么是SSE(Server-Sent Events)? SSE(Server-Sent Events)是一种服务器向客户端推送数据的技术,基于HTTP协议,使用单向连接从服务器向客户端发送**实时更新**。SSE是HTML5规范的一部分,专门用于服务器推送场景。因为普通的HTTP协议,我们需要长时间等待代码生成接口的返回,为了提高用户的体验感,所以我们引用SSE协议的实时更新特性使接口像打印机一样返回数据给前端。而之前的结构化输出不能通过sse流式输出获得,所以我们这里需要用到eino的streamReader类型,后面我会讲解到 #### Hertz SSE 内置库使用 **示例:** ```go func HandleSSE(ctx context.Context, c *app.RequestContext) { // 获取上次事件 ID lastEventID := sse.GetLastEventID(&c.Request) // 创建 SSE Writer w := sse.NewWriter(c) // 写入事件 for i := 0; i < 5; i++ { w.WriteEvent("id-x", "message", []byte("hello world")) time.Sleep(10 * time.Millisecond) } w.Close() } ``` #### 代码内容解析器实现 AI 生成的代码通常包含在 Markdown 代码块中,需要解析器将其提取出来。代码解析器负责从 AI 返回的文本中提取 HTML、CSS 和 JavaScript 代码。 **文件位置:** `internal/core/parser/code_paser.go` **完整代码:** ```go package parser import ( "regexp" "strings" "yikou-ai-go-teach/internal/ai/aimodel" ) var ( htmlCodeRegex = regexp.MustCompile("(?i)```html\\s*\\n([\\s\\S]*?)```") cssCodeRegex = regexp.MustCompile("(?i)```css\\s*\\n([\\s\\S]*?)```") jsCodeRegex = regexp.MustCompile("(?i)```(?:js|javascript)\\s*\\n([\\s\\S]*?)```") ) func ParseHtmlCode(codeContent string) *aimodel.HtmlCodeResponse { result := &aimodel.HtmlCodeResponse{} htmlCode := extractHtmlCode(codeContent) if htmlCode != "" { result.HtmlCode = strings.TrimSpace(htmlCode) } else { result.HtmlCode = strings.TrimSpace(codeContent) } return result } func ParseMultiFileCode(codeContent string) *aimodel.MultiFileCodeResponse { result := &aimodel.MultiFileCodeResponse{} htmlCode := extractCodeByPattern(codeContent, htmlCodeRegex) cssCode := extractCodeByPattern(codeContent, cssCodeRegex) jsCode := extractCodeByPattern(codeContent, jsCodeRegex) if htmlCode != "" { result.HtmlCode = strings.TrimSpace(htmlCode) } if cssCode != "" { result.CssCode = strings.TrimSpace(cssCode) } if jsCode != "" { result.JsCode = strings.TrimSpace(jsCode) } return result } func extractHtmlCode(content string) string { matches := htmlCodeRegex.FindStringSubmatch(content) if len(matches) > 1 { return matches[1] } return "" } func extractCodeByPattern(content string, pattern *regexp.Regexp) string { matches := pattern.FindStringSubmatch(content) if len(matches) > 1 { return matches[1] } return "" } ``` **debug运行测试,得到测试结果:** ![在这里插入图片描述](https://pic.code-nav.cn/post_picture/1969049470100254722/l2PDZRJFXWskaWBz.webp) 可以看到测试成功,剩下的描述字段因为对项目业务没啥作用,所有没对此进行解析 #### 流式输出方法实现 流式输出是提升用户体验的关键技术,通过 SSE 协议实现服务器向客户端的实时数据推送。本节详细介绍流式输出方法的实现。 ##### 修改 AI 服务接口定义 在ai服务接口新增两个流式输出的方法 **文件位置:** `internal/ai/ai_codegen_service.go` **完整代码:** ```go package ai import ( "context" "github.com/cloudwego/eino/schema" "yikou-ai-go-teach/internal/ai/aimodel" ) type IYiKouAiCodegenService interface { GenerateHtmlCode(ctx context.Context, userMessage string) (*aimodel.HtmlCodeResponse, error) GenerateMultiFileCode(ctx context.Context, userMessage string) (*aimodel.MultiFileCodeResponse, error) GenerateHtmlCodeStream(ctx context.Context, userMessage string) (*schema.StreamReader[*schema.Message], error) GenerateMultiFileCodeStream(ctx context.Context, userMessage string) (*schema.StreamReader[*schema.Message], error) } ``` ##### 基础智能体增加流式输出方法 **文件位置:** `internal/ai/agent/base_agent.go` **完整代码:** ```go package agent import ( "context" "errors" "github.com/bytedance/gopkg/util/logger" "github.com/cloudwego/eino-ext/components/model/openai" "github.com/cloudwego/eino/adk" "github.com/cloudwego/eino/components/prompt" "github.com/cloudwego/eino/schema" "io" ) type ChatModelWrapperAdaptor interface { GetChatModel() *openai.ChatModel GetModelName() string } type BaseAgent struct { model *openai.ChatModel modelName string } func NewBaseAgent(chatModel ChatModelWrapperAdaptor) *BaseAgent { return &BaseAgent{ model: chatModel.GetChatModel(), modelName: chatModel.GetModelName(), } } func (a *BaseAgent) GetModel() *openai.ChatModel { return a.model } func (a *BaseAgent) NewAdkAgent(name, description, instruction string) *adk.ChatModelAgent { ctx := context.Background() config := &adk.ChatModelAgentConfig{ Name: name, Description: description, Instruction: instruction, Model: a.model, MaxIterations: 50, ModelRetryConfig: &adk.ModelRetryConfig{ MaxRetries: 3, IsRetryAble: func(ctx context.Context, err error) bool { if errors.Is(err, context.Canceled) { return false } return true }, }, } agent, err := adk.NewChatModelAgent(ctx, config) if err != nil { logger.Errorf("创建Agent失败: %v", err) return nil } return agent } func (a *BaseAgent) Generate(ctx context.Context, userMessage string, chatTemplate prompt.ChatTemplate, adkAgent *adk.ChatModelAgent) (*schema.Message, error) { format, err := chatTemplate.Format(ctx, map[string]any{ "content": userMessage, }) if err != nil { return nil, err } runner := adk.NewRunner(ctx, adk.RunnerConfig{ Agent: adkAgent, EnableStreaming: false, }) iter := runner.Run(ctx, format) var resultMsg *schema.Message for { event, ok := iter.Next() if !ok { break } if event.Err != nil { return nil, event.Err } if event.Output != nil && event.Output.MessageOutput != nil { msg, err := event.Output.MessageOutput.GetMessage() if err != nil { return nil, err } resultMsg = msg } } return resultMsg, nil } func (a *BaseAgent) GenerateStream(ctx context.Context, userMessage string, chatTemplate prompt.ChatTemplate, adkAgent *adk.ChatModelAgent) (*schema.StreamReader[*schema.Message], error) { format, err := chatTemplate.Format(ctx, map[string]any{ "content": userMessage, }) if err != nil { return nil, err } runner := adk.NewRunner(ctx, adk.RunnerConfig{ Agent: adkAgent, EnableStreaming: true, }) iter := runner.Run(ctx, format) reader, writer := schema.Pipe[*schema.Message](2) go func() { defer writer.Close() var fullContent string for { event, ok := iter.Next() if !ok { break } if event.Err != nil { writer.Send(nil, event.Err) return } if event.Output != nil && event.Output.MessageOutput != nil { stream := event.Output.MessageOutput.MessageStream if stream != nil { for { msg, err := stream.Recv() if err == io.EOF { break } if err != nil { writer.Send(nil, err) return } if msg != nil { fullContent += msg.Content writer.Send(msg, nil) } } } } } }() return reader, nil } ``` **关键:eino ADK Agent 的 Runner需要配置流式输出选项** ```go runner := adk.NewRunner(ctx, adk.RunnerConfig{ Agent: adkAgent, EnableStreaming: true, // 关键:启用流式输出 }) ``` **说明:** **1. 创建 Pipe** ```go reader, writer := schema.Pipe[*schema.Message](2) ``` - `Pipe`:创建一个管道,用于在 goroutine 之间传递数据 - `reader`:客户端通过它读取流式数据 - `writer`:在 goroutine 中写入流式数据 - 参数 `2`:管道缓冲区大小 **2. 处理流式事件** ```go if event.Output != nil && event.Output.MessageOutput != nil { stream := event.Output.MessageOutput.MessageStream if stream != nil { for { msg, err := stream.Recv() if err == io.EOF { break } if err != nil { writer.Send(nil, err) return } if msg != nil { fullContent += msg.Content writer.Send(msg, nil) } } } } ``` - `MessageStream`:消息流对象 - `stream.Recv()`:接收流中的下一个消息 - `io.EOF`:流结束标志 - `writer.Send(msg, nil)`:将消息发送到管道 ##### 智能体流式输出实现 **文件位置:** `internal/ai/agent/codegen_agent.go` **流式输出方法代码:** ```go func (a *CodeGenAgent) GenerateHtmlCodeStream(ctx context.Context, userMessage string) (*schema.StreamReader[*schema.Message], error) { chatTemplate, err := myprompt.NewHtmlChatTemplate() if err != nil { return nil, err } adkAgent := a.getAdkAgent() return a.GenerateStream(ctx, userMessage, chatTemplate, adkAgent) } func (a *CodeGenAgent) GenerateMultiFileCodeStream(ctx context.Context, userMessage string) (*schema.StreamReader[*schema.Message], error) { chatTemplate, err := myprompt.NewMultiFileChatTemplate() if err != nil { return nil, err } adkAgent := a.getAdkAgent() return a.GenerateStream(ctx, userMessage, chatTemplate, adkAgent) } ``` ##### 流式输出测试 **文件位置:** `internal/core/ai_codegen_facade_test.go` **完整测试代码:** ```go package core import ( "context" "github.com/cloudwego/hertz/pkg/common/test/assert" "strings" "testing" "yikou-ai-go-teach/config" "yikou-ai-go-teach/internal/ai/agent" "yikou-ai-go-teach/internal/ai/llm" "yikou-ai-go-teach/pkg/enum" ) func TestYiKouAiCodegenFacade_GenCodeAndSave(t *testing.T) { config.SetEnvFlag("local") // 解析命令行参数 initConfig := config.InitConfig() chatModel := llm.NewChatModel(initConfig) codeGenAgent := agent.NewCodeGenAgent(chatModel, enum.MultiFileGen) aiCodegenFacade := NewYiKouAiCodegenFacade(codeGenAgent) err := aiCodegenFacade.GenCodeAndSave(context.Background(), "帮我生成一个日常记录网站", enum.MultiFileGen) if err != nil { panic(err) } } func TestYiKouAiCodegenFacade_GenCodeStreamAndSave(t *testing.T) { config.SetEnvFlag("local") // 解析命令行参数 initConfig := config.InitConfig() chatModel := llm.NewChatModel(initConfig) codeGenAgent := agent.NewCodeGenAgent(chatModel, enum.MultiFileGen) aiCodegenFacade := NewYiKouAiCodegenFacade(codeGenAgent) resp, err := aiCodegenFacade.GenCodeStreamAndSave(context.Background(), "帮我生成一个日常记录网站", enum.MultiFileGen) if err != nil { panic(err) } var builder strings.Builder for { message, err := resp.Recv() if err != nil { break } builder.WriteString(message.Content) } assert.NotNil(t, builder.String()) } ``` ![在这里插入图片描述](https://pic.code-nav.cn/post_picture/1969049470100254722/57kABOfA83ecPiK1.webp) 可以看到,能够正常拼接流式输出 ### 六、优化设计模式 #### 设计模式应用 **1. 策略模式(Strategy Pattern)** 策略模式定义了一系列算法,并将每个算法封装起来,使它们可以相互替换。在解析器设计中,我们定义了统一的解析策略接口。 **策略模式例子:** ![在这里插入图片描述](https://pic.code-nav.cn/post_picture/1969049470100254722/OMwo8KciXYkOrGTv.webp) **2. 执行器模式(Executor Pattern)** 执行器模式提供了一个统一的执行接口,根据不同的类型选择不同的策略执行。在解析器设计中,`CodeParserExecutor` 作为执行器,根据代码生成类型选择对应的解析器。 **执行器模式例子:** ![在这里插入图片描述](https://pic.code-nav.cn/post_picture/1969049470100254722/WAxKvslbUSUhaS5O.png) **3. 模板方法模式(Template Method Pattern)** 模板方法模式是一种行为型设计模式,它在父类中定义了一个算法的骨架,将某些步骤延迟到子类中实现。模板方法使得子类可以在不改变算法结构的情况下,重新定义算法的某些特定步骤。 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 #### 优化解析器设计 为了提高代码的可扩展性和可维护性,我们使用**策略模式**和**执行器模式**对解析器进行优化设计。 **文件位置:** `internal/core/parser/code_paser.go` **完整代码:** ```go package parser import ( "fmt" "regexp" "strings" "yikou-ai-go-teach/internal/ai/aimodel" "yikou-ai-go-teach/pkg/enum" ) // Parser 定义解析策略接口(策略模式) type Parser[T any] interface { Parse(content string) (T, error) } // HtmlCodeParser HTML代码解析器(具体策略A) type HtmlCodeParser struct{} // NewHtmlCodeParser 创建HTML解析器(工厂方法) func NewHtmlCodeParser() *HtmlCodeParser { return &HtmlCodeParser{} } // Parse 实现解析策略 func (p *HtmlCodeParser) Parse(content string) (*aimodel.HtmlCodeResponse, error) { result := &aimodel.HtmlCodeResponse{} matches := htmlCodeRegex.FindStringSubmatch(content) if len(matches) >= 2 { result.HtmlCode = strings.TrimSpace(matches[1]) } return result, nil } // MultiFileCodeParser 多文件代码解析器(具体策略B) type MultiFileCodeParser struct{} // NewMultiFileCodeParser 创建多文件解析器(工厂方法) func NewMultiFileCodeParser() *MultiFileCodeParser { return &MultiFileCodeParser{} } // Parse 实现解析策略 func (p *MultiFileCodeParser) Parse(content string) (*aimodel.MultiFileCodeResponse, error) { result := &aimodel.MultiFileCodeResponse{} htmlMatches := htmlCodeRegex.FindStringSubmatch(content) if len(htmlMatches) >= 2 { result.HtmlCode = strings.TrimSpace(htmlMatches[1]) } cssMatches := cssCodeRegex.FindStringSubmatch(content) if len(cssMatches) >= 2 { result.CssCode = strings.TrimSpace(cssMatches[1]) } jsMatches := jsCodeRegex.FindStringSubmatch(content) if len(jsMatches) >= 3 { result.JsCode = strings.TrimSpace(jsMatches[2]) } return result, nil } // CodeParserExecutor 解析器执行器(执行器模式) type CodeParserExecutor struct { htmlCodeParser *HtmlCodeParser multiFileCodeParser *MultiFileCodeParser } // NewCodeParserExecutor 创建解析器执行器(工厂方法) func NewCodeParserExecutor() *CodeParserExecutor { return &CodeParserExecutor{ htmlCodeParser: NewHtmlCodeParser(), multiFileCodeParser: NewMultiFileCodeParser(), } } // ExecuteParser 执行解析(根据类型选择策略) func (e *CodeParserExecutor) ExecuteParser(content string, parserType enum.CodeGenTypeEnum) (interface{}, error) { switch parserType { case enum.HtmlCodeGen: return e.htmlCodeParser.Parse(content) case enum.MultiFileGen: return e.multiFileCodeParser.Parse(content) default: return nil, fmt.Errorf("不支持的解析类型: %s", parserType) } } // 正则表达式定义 var ( htmlCodeRegex = regexp.MustCompile("(?i)```html\\s*\\n([\\s\\S]*?)```") cssCodeRegex = regexp.MustCompile("(?i)```css\\s*\\n([\\s\\S]*?)```") jsCodeRegex = regexp.MustCompile("(?i)```(?:js|javascript)\\s*\\n([\\s\\S]*?)```") ) // 以下是保留的函数式方法,用于向后兼容 func ParseHtmlCode(codeContent string) *aimodel.HtmlCodeResponse { result := &aimodel.HtmlCodeResponse{} htmlCode := extractHtmlCode(codeContent) if htmlCode != "" { result.HtmlCode = strings.TrimSpace(htmlCode) } else { result.HtmlCode = strings.TrimSpace(codeContent) } return result } func ParseMultiFileCode(codeContent string) *aimodel.MultiFileCodeResponse { result := &aimodel.MultiFileCodeResponse{} htmlCode := extractCodeByPattern(codeContent, htmlCodeRegex) cssCode := extractCodeByPattern(codeContent, cssCodeRegex) jsCode := extractCodeByPattern(codeContent, jsCodeRegex) if htmlCode != "" { result.HtmlCode = strings.TrimSpace(htmlCode) } if cssCode != "" { result.CssCode = strings.TrimSpace(cssCode) } if jsCode != "" { result.JsCode = strings.TrimSpace(jsCode) } return result } func extractHtmlCode(content string) string { matches := htmlCodeRegex.FindStringSubmatch(content) if len(matches) > 1 { return matches[1] } return "" } func extractCodeByPattern(content string, pattern *regexp.Regexp) string { matches := pattern.FindStringSubmatch(content) if len(matches) > 1 { return matches[1] } return "" } ``` #### 优化保存器设计 为了提高代码的可扩展性和可维护性,我们使用**模板方法模式**和**执行器模式**对保存器进行优化设计。 **文件位置:** `internal/core/saver/codefile_saver.go` **完整代码:** ```go package saver import ( "fmt" "github.com/sony/sonyflake" "os" "path/filepath" "strconv" "yikou-ai-go-teach/internal/ai/aimodel" "yikou-ai-go-teach/pkg/enum" "yikou-ai-go-teach/pkg/myfile" ) // buildUniqueDir 构建唯一的目录名 // 目录名格式: {代码生成类型}_{唯一ID} func (d *CodeFileSaverTemplate[T]) buildUniqueDir(appId int64) (string, error) { if appId == 0 { return "", fmt.Errorf("应用id不能为空") } //构建唯一目录名 fileSaveDir, err := myfile.GetCodeOutputRoot() uniqueDirName := fmt.Sprintf("%s_%s", d.getCodeType(), strconv.FormatUint(uint64(appId), 20)) dirPath := filepath.Join(fileSaveDir, uniqueDirName) // 创建目录 err = os.MkdirAll(dirPath, os.ModePerm) if err != nil { return "", err } return dirPath, nil } // writeToFile 将内容写入文件并保存 func writeToFile(dirPath string, fileName string, content string) error { filePath := filepath.Join(dirPath, fileName) err := os.WriteFile(filePath, []byte(content), os.ModePerm) if err != nil { return err } return nil } // SaveHtmlCode 保存 HTML 代码文件 func SaveHtmlCode(response aimodel.HtmlCodeResponse) (string, error) { dirPath, err := buildUniqueDir(enum.HtmlCodeGen) if err != nil { return "", err } fileName := "index.html" return dirPath, writeToFile(dirPath, fileName, response.HtmlCode) } // SaveMultiFileCode 保存多文件代码文件 func SaveMultiFileCode(response aimodel.MultiFileCodeResponse) (string, error) { dirPath, err := buildUniqueDir(enum.MultiFileGen) if err != nil { return "", err } // 保存 HTML 文件 err = writeToFile(dirPath, "index.html", response.HtmlCode) if err != nil { return "", err } // 保存 JS 文件 err = writeToFile(dirPath, "script.js", response.JsCode) if err != nil { return "", err } // 保存 CSS 文件 err = writeToFile(dirPath, "style.css", response.CssCode) if err != nil { return "", err } return dirPath, nil } type CodeFileSaver[T any] interface { getCodeType() enum.CodeGenTypeEnum saveFiles(response T, baseDir string) error validateInput(response T) error } type CodeFileSaverTemplate[T any] struct { CodeFileSaver[T] } func (d *CodeFileSaverTemplate[T]) saveCode(response T) (string, error) { err := d.validateInput(response) if err != nil { return "", err } dirPath, err := d.buildUniqueDir() if err != nil { return "", err } return dirPath, d.saveFiles(response, dirPath) } // buildUniqueDir 构建唯一的目录名 // 目录名格式: {代码生成类型}_{唯一ID} func (d *CodeFileSaverTemplate[T]) buildUniqueDir() (string, error) { // 生成雪花id var sf = sonyflake.NewSonyflake(sonyflake.Settings{ MachineID: func() (uint16, error) { return 1, nil }, }) id, err := sf.NextID() if err != nil { return "", err } //构建唯一目录名 dirPath := fmt.Sprintf("%s_%s", d.getCodeType(), strconv.FormatUint(id, 20)) // 创建目录 err = os.MkdirAll(dirPath, os.ModePerm) if err != nil { return "", err } return dirPath, nil } // writeToFile 将内容写入文件并保存 func (d *CodeFileSaverTemplate[T]) writeToFile(dirPath string, fileName string, content string) error { filePath := filepath.Join(dirPath, fileName) err := os.WriteFile(filePath, []byte(content), os.ModePerm) if err != nil { return err } return nil } type HtmlCodeFileSaverTemplate struct { CodeFileSaverTemplate[*aimodel.HtmlCodeResponse] } func NewHtmlCodeFileSaverTemplate() *HtmlCodeFileSaverTemplate { t := &HtmlCodeFileSaverTemplate{} t.CodeFileSaverTemplate.CodeFileSaver = t return t } func (h *HtmlCodeFileSaverTemplate) getCodeType() enum.CodeGenTypeEnum { return enum.HtmlCodeGen } func (h *HtmlCodeFileSaverTemplate) saveFiles(response *aimodel.HtmlCodeResponse, baseDir string) error { fileName := "index.html" return h.writeToFile(baseDir, fileName, response.HtmlCode) } func (h *HtmlCodeFileSaverTemplate) validateInput(response *aimodel.HtmlCodeResponse) error { if response == nil { return fmt.Errorf("代码结果为空") } if response.HtmlCode == "" { return fmt.Errorf("HTML 代码为空") } return nil } type MultiFileCodeFileSaverTemplate struct { CodeFileSaverTemplate[*aimodel.MultiFileCodeResponse] } func NewMultiFileCodeFileSaverTemplate() *MultiFileCodeFileSaverTemplate { t := &MultiFileCodeFileSaverTemplate{} t.CodeFileSaverTemplate.CodeFileSaver = t return t } func (m *MultiFileCodeFileSaverTemplate) getCodeType() enum.CodeGenTypeEnum { return enum.MultiFileGen } func (m *MultiFileCodeFileSaverTemplate) saveFiles(response *aimodel.MultiFileCodeResponse, baseDir string) error { // 保存 HTML 文件 err := m.writeToFile(baseDir, "index.html", response.HtmlCode) if err != nil { return err } // 保存 JS 文件 err = m.writeToFile(baseDir, "script.js", response.JsCode) if err != nil { return err } // 保存 CSS 文件 err = m.writeToFile(baseDir, "style.css", response.CssCode) if err != nil { return err } return nil } func (m *MultiFileCodeFileSaverTemplate) validateInput(response *aimodel.MultiFileCodeResponse) error { if response == nil { return fmt.Errorf("代码结果为空") } if response.HtmlCode == "" { return fmt.Errorf("HTML 代码为空") } if response.JsCode == "" { return fmt.Errorf("JS 代码为空") } if response.CssCode == "" { return fmt.Errorf("CSS 代码为空") } return nil } type CodeFileSaverExecutor struct { htmlCodeFileSaver *HtmlCodeFileSaverTemplate multiFileCodeFileSaver *MultiFileCodeFileSaverTemplate } func NewCodeFileSaverExecutor() *CodeFileSaverExecutor { return &CodeFileSaverExecutor{ htmlCodeFileSaver: NewHtmlCodeFileSaverTemplate(), multiFileCodeFileSaver: NewMultiFileCodeFileSaverTemplate(), } } func (e *CodeFileSaverExecutor) ExecuteSaver(content interface{}, saveType enum.CodeGenTypeEnum) (string, error) { switch saveType { case enum.HtmlCodeGen: return e.htmlCodeFileSaver.saveCode(content.(*aimodel.HtmlCodeResponse)) case enum.MultiFileGen: return e.multiFileCodeFileSaver.saveCode(content.(*aimodel.MultiFileCodeResponse)) default: return "", fmt.Errorf("不支持的代码文件类型: %s", saveType) } } ``` #### 优化门面结构体流式方法 **文件位置:** `internal/core/ai_codegen_facade.go` 增加门面结构体的属性;增加流式处理方法,该方法主要负责调用解析器执行器和保存器执行器 ```go // YiKouAiCodegenFacade AI代码生成门面(门面模式) type YiKouAiCodegenFacade struct { codegenService ai.IYiKouAiCodegenService // AI代码生成服务 codeParserExecutor *parser.CodeParserExecutor // 代码解析器执行器 codeFileSaverExecutor *saver.CodeFileSaverExecutor // 代码文件保存器执行器 } // NewYiKouAiCodegenFacade 创建AI代码生成门面 func NewYiKouAiCodegenFacade(codegenService ai.IYiKouAiCodegenService, codeParserExecutor *parser.CodeParserExecutor, codeFileSaverExecutor *saver.CodeFileSaverExecutor) *YiKouAiCodegenFacade { return &YiKouAiCodegenFacade{ codegenService: codegenService, codeParserExecutor: codeParserExecutor, codeFileSaverExecutor: codeFileSaverExecutor, } } // processCodeStream 处理代码流式数据并保存 func (y *YiKouAiCodegenFacade) processCodeStream(respStream *schema.StreamReader[*schema.Message], typeStr enum.CodeGenTypeEnum) (*schema.StreamReader[*schema.Message], error) { // 先复制流,一个用于处理,一个返回给上游 streams := respStream.Copy(2) processingStream := streams[0] returnStream := streams[1] // 在 goroutine 中处理流数据,不阻塞返回 go func() { var builder strings.Builder defer processingStream.Close() for { chunk, err := processingStream.Recv() if err == io.EOF { break } if err != nil { return } builder.WriteString(chunk.Content) } // 解析代码 parsedResp, err := y.codeParserExecutor.ExecuteParser(builder.String(), typeStr) if err != nil { return } // 保存代码 dirPath, err := y.codeFileSaverExecutor.ExecuteSaver(parsedResp, typeStr) if err != nil { return } logger.Info("代码已保存到目录: %s", dirPath) }() return returnStream, nil } // GenCodeStreamAndSave 根据类型生成代码流式输出并保存 func (y *YiKouAiCodegenFacade) GenCodeStreamAndSave(ctx context.Context, userMessage string, typeStr enum.CodeGenTypeEnum) (*schema.StreamReader[*schema.Message], error) { switch typeStr { case enum.HtmlCodeGen: streamResp, err := y.codegenService.GenerateHtmlCodeStream(ctx, userMessage) if err != nil { return nil, err } return y.processCodeStream(streamResp, typeStr) case enum.MultiFileGen: streamResp, err := y.codegenService.GenerateMultiFileCodeStream(ctx, userMessage) if err != nil { return nil, err } return y.processCodeStream(streamResp, typeStr) default: return nil, fmt.Errorf("不支持的代码生成类型: %s", typeStr) } } ``` #### 修改门面结构体的测试方法 **文件位置:** `internal/core/ai_codegen_facade_test.go` **完整测试代码:** ```go package core import ( "context" "github.com/cloudwego/hertz/pkg/common/test/assert" "strings" "testing" "yikou-ai-go-teach/config" "yikou-ai-go-teach/internal/ai/agent" "yikou-ai-go-teach/internal/ai/llm" "yikou-ai-go-teach/internal/core/parser" "yikou-ai-go-teach/internal/core/saver" "yikou-ai-go-teach/pkg/enum" ) // TestYiKouAiCodegenFacade_GenCodeAndSave 测试非流式代码生成和保存 func TestYiKouAiCodegenFacade_GenCodeAndSave(t *testing.T) { config.SetEnvFlag("local") // 初始化配置 initConfig := config.InitConfig() // 创建聊天模型 chatModel := llm.NewChatModel(initConfig) // 创建代码生成智能体 codeGenAgent := agent.NewCodeGenAgent(chatModel, enum.MultiFileGen) // 创建解析器执行器 parserExecutor := parser.NewCodeParserExecutor() // 创建保存器执行器 fileSaverExecutor := saver.NewCodeFileSaverExecutor() // 创建门面对象 aiCodegenFacade := NewYiKouAiCodegenFacade(codeGenAgent, parserExecutor, fileSaverExecutor) // 执行代码生成和保存 err := aiCodegenFacade.GenCodeAndSave(context.Background(), "帮我生成一个日常记录网站", enum.MultiFileGen) if err != nil { panic(err) } } // TestYiKouAiCodegenFacade_GenCodeStreamAndSave 测试流式代码生成和保存 func TestYiKouAiCodegenFacade_GenCodeStreamAndSave(t *testing.T) { config.SetEnvFlag("local") // 初始化配置 initConfig := config.InitConfig() // 创建聊天模型 chatModel := llm.NewChatModel(initConfig) // 创建代码生成智能体 codeGenAgent := agent.NewCodeGenAgent(chatModel, enum.MultiFileGen) // 创建解析器执行器 parserExecutor := parser.NewCodeParserExecutor() // 创建保存器执行器 fileSaverExecutor := saver.NewCodeFileSaverExecutor() // 创建门面对象 aiCodegenFacade := NewYiKouAiCodegenFacade(codeGenAgent, parserExecutor, fileSaverExecutor) // 执行流式代码生成和保存 resp, err := aiCodegenFacade.GenCodeStreamAndSave(context.Background(), "帮我生成一个日常记录网站", enum.MultiFileGen) if err != nil { panic(err) } // 读取流式数据 var builder strings.Builder for { message, err := resp.Recv() if err != nil { break } builder.WriteString(message.Content) } // 验证结果 assert.NotNil(t, builder.String()) } ``` 测试方法这里我就具体调试查看效果了,大家可以自行测试。通过本章的代码优化,大部分的业务逻辑都显著地提高了代码可读性和可维护性,当我们需要对项目新增业务逻辑时,我们的修改工作只需增加新的处理方法,而不需要修改主要的业务方法。**我们现在已经封装好了智能体,以及对现有的代码进行大幅度的优化,在下一章,我们将实现后端的应用生成模块,我们的代码生成智能体将会进一步拓展成应用生成平台,请大家敬请期待!**

易扣AI (Go + CloudWeGo) 企业级AI智能体项目教程 第2章:后端项目用户模块搭建

## 本章概述 恭喜各位小伙伴进入第二章!在这一章,我们将学习如何使用 Google 的 go-wire 工具进行依赖注入,搭建一个完整的用户模块。依赖注入是构建大型企业级项目的关键技术,能帮助你写出更清晰、更易测试的代码。 ## 知识点清单 ### 一、go-wire 前提准备 #### 1. 什么是 go-wire? **go-wire 简介:** go-wire 是 Google 开源的一个 Go 语言**依赖注入代码生成工具**。它通过编译时代码生成的方式,自动处理组件之间的依赖关系。 **核心特点:** - **编译时生成**:在编译时生成依赖注入代码,而非运行时反射 - **类型安全**:编译时检查依赖关系,避免运行时错误 - **性能优异**:生成的代码性能接近手写代码 - **简单易用**:通过简单的配置即可自动生成复杂的初始化代码 #### 2. 为什么需要依赖注入? **问题场景:手动管理依赖** 假设我们有以下结构: ```go // 用户处理器 type UserHandler struct { userService *UserService } // 用户服务 type UserService struct { userRepo *UserRepository } // 用户仓储 type UserRepository struct { db *gorm.DB } // 传统方式:手动创建依赖 func main() { // 1. 创建数据库连接 db, _ := gorm.Open(mysql.Open(dsn), &gorm.Config{}) // 2. 创建仓储 userRepo := &UserRepository{db: db} // 3. 创建服务 userService := &UserService{userRepo: userRepo} // 4. 创建处理器 userHandler := &UserHandler{userService: userService} // 使用处理器... } ``` **存在的问题:** - **依赖关系复杂**:需要手动管理创建顺序 - **代码重复**:每个地方都需要重复创建逻辑 - **难以测试**:无法轻松替换依赖进行测试 - **维护困难**:修改依赖关系需要改动多处代码 **解决方案:使用 go-wire** ```go // wire.go //+build wireinject package main import "github.com/google/wire" func InitializeUserHandler(db *gorm.DB) *UserHandler { wire.Build( NewUserRepository, NewUserService, NewUserHandler, ) return nil } // wire_gen.go (自动生成) func InitializeUserHandler(db *gorm.DB) *UserHandler { userRepository := NewUserRepository(db) userService := NewUserService(userRepository) userHandler := NewUserHandler(userService) return userHandler } ``` **优势:** - **自动管理依赖**:wire 自动分析依赖关系 - **类型安全**:编译时检查依赖是否完整 - **易于测试**:可以轻松替换依赖实现 - **代码清晰**:依赖关系一目了然 #### 3. 安装 go-wire **在项目中添加依赖:** ```bash # 添加 wire 依赖到 go.mod go get github.com/google/wire ``` **安装 wire 命令行工具:** ```bash # 安装 wire go get github.com/google/wire/cmd/wire # 验证安装 wire version ``` #### 4. go-wire 核心概念 **Provider(提供者):** Provider 是一个可以产生值的函数,用于创建依赖对象。 ```go // Provider 示例 // 简单的 Provider func NewDB() (*gorm.DB, error) { return gorm.Open(mysql.Open(dsn), &gorm.Config{}) } // 带依赖的 Provider func NewUserRepository(db *gorm.DB) *UserRepository { return &UserRepository{db: db} } // 带清理函数的 Provider func NewRedis() (*redis.Client, func(), error) { client := redis.NewClient(opts) cleanup := func() { client.Close() } return client, cleanup, nil } ``` **Injector(注入器):** Injector 是一个声明依赖关系的函数,wire 会根据它生成实际的初始化代码。 ```go //+build wireinject package main import "github.com/google/wire" // Injector 函数声明 func InitializeApp() (*App, func(), error) { wire.Build( NewDB, // 提供 *gorm.DB NewUserRepo, // 需要 *gorm.DB,提供 *UserRepository NewUserService, // 需要 *UserRepository,提供 *UserService NewApp, // 需要 *UserService,提供 *App ) // 以下代码省略 ...... } ``` **WireSet(依赖集合):** WireSet 用于将一组相关的 Provider 组合在一起。 ```go // 定义 User 模块的 Provider Set var UserSet = wire.NewSet( NewUserRepository, NewUserService, NewUserHandler, ) // 定义数据库的 Provider Set var DBSet = wire.NewSet( NewDB, NewRedis, ) // 在 Injector 中使用 func InitializeApp() (*App, error) { wire.Build( DBSet, // 数据库相关 UserSet, // 用户模块相关 NewApp, ) // 以下代码省略 ...... } ``` #### 5. go-wire 使用流程 **完整的使用流程:** ``` 1. 定义 Provider 函数 ↓ 2. 创建 Injector 函数(添加 wire.Build) ↓ 3. 运行 wire 命令生成代码 ↓ 4. 使用生成的初始化函数 ``` #### 6. go-wire 最佳实践 **1. Provider 命名规范** ```go // ✅ 推荐:使用 New 前缀 func NewUserRepository(db *gorm.DB) *UserRepository func NewUserService(repo *UserRepository) *UserService func NewUserHandler(service *UserService) *UserHandler // ❌ 不推荐:其他命名 func CreateUserRepository(db *gorm.DB) *UserRepository func GetUserRepository(db *gorm.DB) *UserRepository ``` **2. 使用 WireSet 组织依赖** ```go // internal/repository/wire.go package repository import "github.com/google/wire" var RepositorySet = wire.NewSet( NewUserRepository, NewChatRepository, NewMessageRepository, ) // internal/service/wire.go package service import "github.com/google/wire" var ServiceSet = wire.NewSet( NewUserService, NewChatService, NewMessageService, ) // cmd/wire.go func InitializeApp() (*App, error) { wire.Build( repository.RepositorySet, service.ServiceSet, handler.HandlerSet, NewApp, ) return nil } ``` **3. 接口与实现分离** ```go // 定义接口 type IUserService interface { GetUser(id int) (*User, error) CreateUser(user *User) error } // 实现接口 type UserService struct { repo IUserRepository } // Provider 返回接口类型 func NewUserService(repo IUserRepository) IUserService { return &UserService{repo: repo} } ``` **4. 错误处理** ```go // Provider 可以返回 error func NewDB(cfg *config.Config) (*gorm.DB, error) { db, err := gorm.Open(mysql.Open(cfg.DSN), &gorm.Config{}) if err != nil { return nil, fmt.Errorf("failed to connect database: %w", err) } return db, nil } // Injector 也会返回 error func InitializeApp() (*App, error) { wire.Build( NewDB, NewApp, ) return nil } ``` ### 二、用户模块架构设计 在完成了 go-wire 的学习之后,我们现在开始搭建用户模块。架构设计的第一步是**数据库表方案设计**,这是整个模块的基础。 #### 1. 数据库设计原则 **企业级项目数据库设计原则:** 1. **命名规范** - 表名:使用小写字母,单词间用下划线分隔(如 `user`, `chat_message`) - 字段名:使用小写字母,单词间用下划线分隔(如 `user_name`, `create_time`) - 索引名:使用前缀标识类型(如 `uk_` 唯一索引,`idx_` 普通索引) 2. **字段设计规范** - 主键:使用 `bigint` 自增,便于分库分表 - 时间字段:统一使用 `datetime` 类型 - 状态字段:使用 `tinyint` 类型 - 字符串:根据实际需求选择合适的长度 - 必须字段:添加 `not null` 约束 3. **索引设计原则** - 为查询频繁的字段添加索引 - 唯一约束字段添加唯一索引 - 避免过多索引影响写入性能 - 联合索引遵循最左前缀原则 4. **通用字段设计** - `id`:主键,bigint 自增 - `create_time`:创建时间,自动设置 - `update_time`:更新时间,自动更新 - `is_delete`:逻辑删除标识,0-未删除,1-已删除 #### 2. 用户表设计方案 **用户表结构设计:** ```sql -- 创建库 create database if not exists yikou_ai; -- 切换库 use yikou_ai; -- 用户表 create table if not exists user ( id bigint auto_increment comment 'id' primary key, user_account varchar(256) not null comment '账号', user_password varchar(512) not null comment '密码', user_name varchar(256) null comment '用户昵称', user_avatar varchar(1024) null comment '用户头像', user_profile varchar(512) null comment '用户简介', user_role varchar(256) default 'user' not null comment '用户角色:user/admin', edit_time datetime default CURRENT_TIMESTAMP not null comment '编辑时间', create_time datetime default CURRENT_TIMESTAMP not null comment '创建时间', update_time datetime default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '更新时间', is_delete tinyint default 0 not null comment '是否删除', UNIQUE KEY uk_userAccount (user_account), INDEX idx_userName (user_name) ) comment '用户' collate = utf8mb4_unicode_ci; ``` #### 3. 字段详细说明 **字段设计详解:** | 字段名 | 类型 | 约束 | 默认值 | 说明 | 设计理由 | | ----------------- | ------------- | --------------------------- | ----------------- | ------------ | -------------------------------------- | | `id` | bigint | PRIMARY KEY, AUTO_INCREMENT | - | 主键ID | 使用 bigint 支持大数据量,便于分库分表 | | `user_account` | varchar(256) | NOT NULL, UNIQUE | - | 用户账号 | 唯一约束保证账号不重复,256长度足够 | | `user_password` | varchar(512) | NOT NULL | - | 用户密码 | 512长度支持加密后的密码存储 | | `user_name` | varchar(256) | NULL | - | 用户昵称 | 允许为空,用户可以不设置昵称 | | `user_avatar` | varchar(1024) | NULL | - | 用户头像URL | 存储头像图片的URL地址 | | `user_profile` | varchar(512) | NULL | - | 用户简介 | 用户个人简介,可选字段 | | `user_role` | varchar(256) | NOT NULL | 'user' | 用户角色 | 默认普通用户,支持扩展更多角色 | | `edit_time` | datetime | NOT NULL | CURRENT_TIMESTAMP | 编辑时间 | 记录最后编辑时间 | | `create_time` | datetime | NOT NULL | CURRENT_TIMESTAMP | 创建时间 | 自动设置为当前时间 | | `update_time` | datetime | NOT NULL | CURRENT_TIMESTAMP | 更新时间 | 自动更新为当前时间 | | `is_delete` | tinyint | NOT NULL | 0 | 逻辑删除标识 | 0-未删除,1-已删除 | #### 4. 索引设计说明 **索引设计详解:** ```sql -- 主键索引(自动创建) PRIMARY KEY (id) -- 唯一索引:保证账号唯一性 UNIQUE KEY uk_userAccount (user_account) -- 普通索引:加速按昵称查询 INDEX idx_userName (user_name) ``` 在数据库可视化界面、本地命令行执行以上创建表的sql语句,或者在GoLand连接到自己的MySQL数据库,新建查询控制台执行sql语句(推荐),因为在后续步骤中,将代码开发集中在ide中可以提高项目的开发效率 ![在这里插入图片描述](https://pic.code-nav.cn/post_picture/1969049470100254722/s9O1u0FWZfbGiJIa.webp) ![在这里插入图片描述](https://pic.code-nav.cn/post_picture/1969049470100254722/25f4UQnEqGTv3vX0.webp) ![在这里插入图片描述](https://pic.code-nav.cn/post_picture/1969049470100254722/eEnoydXkZXTWnFds.webp) ![在这里插入图片描述](https://pic.code-nav.cn/post_picture/1969049470100254722/JgeHFo93ilxRL0KK.webp) **建议:作为一位优秀的程序员,务必将sql表设计文件保存到项目的目录中,例如 `/sql/create_table.sql`,便于团队的其他开发者更快地了解整个项目的设计架构** ### 三、开始后端用户模块开发 在完成了 go-wire 的学习和数据库表设计之后,我们现在开始实际开发用户模块。第一步是**搭建依赖注入架构**,这是整个项目的基础框架。 #### 1. 项目依赖注入架构设计 **依赖关系图:** ``` Config (配置) ↓ Server (服务器) ↓ Router (路由) ↓ Handler (处理器) ↓ Service (业务逻辑) ↓ Db (数据访问) ``` #### 2. 创建配置初始化 Provider **配置结构体设计:** 修改 `config/config.go` 文件的InitConfig方法: ```go // InitConfig 初始化配置 - 这是一个 Provider 函数 // env 参数用于指定配置文件后缀,如 "local" 会读取 config-local.yaml func InitConfig() *Config { // 解析命令行参数 env := flag.String("env", "", "运行环境,如 local, dev, test, prod") flag.Parse() // 获取项目根路径 rootPath, err := GetProjectRootPath() if err != nil { panic(fmt.Errorf("获取项目根路径失败: %w", err)) } // 拼接配置文件目录路径 configPath := filepath.Join(rootPath, "config") // 确定配置文件名称 configName := "config" if *env != "" { configName = fmt.Sprintf("config-%s", *env) } // 设置配置文件名和路径 viper.SetConfigName(configName) // 配置文件名称 viper.SetConfigType("yml") // 配置文件类型 viper.AddConfigPath(configPath) // 配置文件路径 // 读取环境变量 viper.AutomaticEnv() // 读取配置文件 if err := viper.ReadInConfig(); err != nil { panic(fmt.Errorf("读取配置文件失败: %w", err)) } // 解析配置到结构体 cfg := &Config{} if err := viper.Unmarshal(cfg); err != nil { panic(fmt.Errorf("解析配置失败: %w", err)) } return cfg } ``` #### 3. 创建 Wire 依赖注入配置 **创建 wire 目录和文件:** 创建 `wire/wire.go` 文件: ```go //go:build wireinject package wire import ( "fmt" "strconv" "github.com/cloudwego/hertz/pkg/app/server" "github.com/google/wire" "github.com/hertz-contrib/swagger" "yikou-ai-go-teach/config" "yikou-ai-go-teach/docs" "yikou-ai-go-teach/internal/router" ) // 配置依赖 Provider Set var configSet = wire.NewSet( config.InitConfig, // 提供 *Config ) // initServer 初始化 Web 服务器 - 这是一个 Provider 函数 func initServer(cfg *config.Config) *server.Hertz { // 动态设置 Swagger 信息 docs.SwaggerInfo.Host = fmt.Sprintf("localhost:%d", cfg.Server.Port) docs.SwaggerInfo.BasePath = cfg.Server.ContextPath // 初始化 swagger 路径 swaggerPath := fmt.Sprintf("http://localhost:%d%s/swagger/doc.json", cfg.Server.Port, cfg.Server.ContextPath) url := swagger.URL(swaggerPath) // 创建 Hertz 服务器 h := server.Default( server.WithHostPorts(":"+strconv.Itoa(cfg.Server.Port)), server.WithBasePath(cfg.Server.ContextPath), ) // 注册路由 router.RegisterRoutes(h, url) return h } // InitializeApp 初始化所有依赖(依赖图) // 这是 Injector 函数,wire 会根据它生成实际的初始化代码 func InitializeApp() (*server.Hertz, error) { panic(wire.Build( initServer, // 需要 *Config,提供 *server.Hertz configSet, // 提供 *Config )) } ``` #### 4. 生成 Wire 代码 **运行 wire 命令:** ```bash # 在 wire 目录下执行 cd wire wire ``` **生成的 `wire/wire_gen.go` 文件:** ```go // Code generated by Wire. DO NOT EDIT. //go:generate go run github.com/google/wire/cmd/wire //go:build !wireinject // +build !wireinject package wire import ( "fmt" "strconv" "github.com/cloudwego/hertz/pkg/app/server" "github.com/hertz-contrib/swagger" "yikou-ai-go-teach/config" "yikou-ai-go-teach/docs" "yikou-ai-go-teach/internal/router" ) // InitializeApp is initialized by wire: // InitializeApp = initServer(config.InitConfig) func InitializeApp() (*server.Hertz, error) { configConfig := config.InitConfig() hertz := initServer(configConfig) return hertz, nil } ``` #### 5. 修改主程序入口 **修改 `main.go` 文件:** ```go package main import ( "fmt" "yikou-ai-go-teach/wire" ) func main() { // 初始化 Web 服务器(使用 wire 生成的初始化函数) h, err := wire.InitializeApp() if err != nil { panic(fmt.Errorf("依赖注入初始化失败: %w", err)) } // 启动服务器 h.Spin() } ``` #### 6. 使用 GORM Gen 生成实体结构体 GORM Gen 是 GORM 的代码生成工具,可以根据数据库表结构自动生成 Go 代码,包括: - 模型结构体(Model) - 查询接口(Query) - 基础 CRUD 方法 **为什么使用 GORM Gen?** 相比于手写 Model,使用 GORM Gen 有以下显著优势: **1)开发效率大幅提升** - 手写 Model 需要为每个表手动定义结构体、字段标签、方法等,耗时且容易出错 - GORM Gen 可以根据数据库表结构自动生成所有代码,只需运行一个命令即可完成 - 对于大型项目,可以节省数小时甚至数天的开发时间 **2) 类型安全得到保障** - 手写 Model 在查询时容易写错字段名,只能在运行时发现错误 - GORM Gen 生成的代码提供类型安全的查询方法,编译时就能发现错误 - 例如:`q.User.UserName.Eq(name)` 比 `db.Where("user_name = ?", name)` 更安全 **3) 维护成本显著降低** - 手写 Model 在修改表结构后,需要手动更新多处代码,容易遗漏 - GORM Gen 只需重新运行生成命令,所有相关代码自动更新 - 避免了因忘记更新代码导致的运行时错误 GORM Gen还有更多功能以及相关介绍可以访问[官方文档](https://gorm.io/gen/index.html)咨询,接下来我们将会频繁地使用GORM Gen生成数据库表相关的代码文件 **创建代码生成脚本:** 创建 `cmd/gen/main.go` 文件: ```go package main import ( "gorm.io/gen" "yikou-ai-go-teach/config" "yikou-ai-go-teach/internal/dal" ) func main() { // 1. 初始化配置 initConfig := config.InitConfig() // 2. 初始化数据库连接 db := dal.InitDB(initConfig) // 3. 创建 Gen 生成器 g := gen.NewGenerator(gen.Config{ OutPath: "./internal/dal/query", // 查询代码输出路径 ModelPkgPath: "model", // 模型包路径 Mode: gen.WithoutContext | // 生成的代码不包含 context gen.WithDefaultQuery | // 生成默认查询方法 gen.WithQueryInterface, // 生成查询接口 }) // 4. 使用数据库连接 g.UseDB(db) // 5. 为所有表生成代码 g.ApplyBasic(g.GenerateAllTable()...) // 6. 执行生成 g.Execute() } ``` **生成器配置说明:** | 配置项 | 值 | 说明 | | ---------------------- | ------------------------ | ----------------------------- | | `OutPath` | `./internal/dal/query` | 生成的查询代码存放路径 | | `ModelPkgPath` | `model` | 生成的模型结构体包名 | | `WithoutContext` | - | 生成的代码不包含 context 参数 | | `WithDefaultQuery` | - | 生成默认的 CRUD 查询方法 | | `WithQueryInterface` | - | 生成查询接口,便于测试 | **运行代码生成脚本,得到生成好的文件:** ![在这里插入图片描述](https://pic.code-nav.cn/post_picture/1969049470100254722/L3EDnzVhCWkuriwZ.png) #### 7. 数据库初始化 Provider 在完成了依赖注入架构搭建后,我们需要添加数据库连接的初始化。这是数据访问层的基础。 **创建数据库初始化文件:** 创建 `internal/dal/init.go` 文件 ```go package dal import ( "fmt" "gorm.io/driver/mysql" "gorm.io/gorm" "gorm.io/gorm/logger" "yikou-ai-go-teach/config" ) // InitDB 初始化数据库连接 - 这是一个 Provider 函数 func InitDB(config *config.Config) *gorm.DB { // 检查配置是否为空 if config == nil { panic(fmt.Errorf("配置加载失败")) } // 获取数据库连接字符串 dsn := config.Database.GetDSN() // 连接数据库 db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{ Logger: logger.Default.LogMode(logger.Info), // 设置日志级别 }) if err != nil { panic(fmt.Errorf("数据库连接失败: %w", err)) } query.SetDefault(db) return db } ``` **更新 wire.go 添加数据库依赖:** ```go // 数据库依赖 Provider Set var dbSet = wire.NewSet( dal.InitDB, // 提供 *gorm.DB ) // 更新 Injector func InitializeApp() (*server.Hertz, error) { panic(wire.Build( configSet, // 配置 dbSet, // 数据库 initServer, // 服务器 )) } ``` #### 8. 用户模型开发 在生成了基础的 User 模型后,我们需要对其进行一些定制化修改,并添加必要的工具类。 ##### 雪花 ID 生成器 **为什么使用雪花 ID?** 雪花 ID(Snowflake ID)是 Twitter 开源的分布式 ID 生成算法,具有以下优势: | 特性 | 自增 ID | 雪花 ID | | ------------ | -------- | -------- | | 唯一性 | 单机唯一 | 全局唯一 | | 有序性 | 严格递增 | 趋势递增 | | 性能 | 高 | 极高 | | 分布式支持 | ❌ | ✅ | | 信息泄露风险 | 高 | 低 | **雪花 ID 的组成:** ``` 0 - 41位时间戳 - 10位机器ID - 12位序列号 总共 64 位(int64): - 1 位符号位(始终为 0) - 41 位时间戳(毫秒级,可使用 69 年) - 10 位机器 ID(支持 1024 台机器) - 12 位序列号(每毫秒可生成 4096 个 ID) ``` **创建雪花 ID 生成器:** 创建 `pkg/snowflake/snowflake.go` 文件: ```go package snowflake import ( "strconv" "github.com/sony/sonyflake" ) var ( sf *sonyflake.Sonyflake ) // init 初始化雪花 ID 生成器 func init() { sf = sonyflake.NewSonyflake(sonyflake.Settings{ MachineID: func() (uint16, error) { return 1, nil }, // 机器 ID,分布式环境下应动态获取 }) } // GenerateSnowFlakeId 生成雪花 ID(int64) func GenerateSnowFlakeId() (int64, error) { id, err := sf.NextID() if err != nil { return 0, err } return int64(id), nil } // GenerateSnowFlakeIdString 生成雪花 ID(string) func GenerateSnowFlakeIdString() (string, error) { snowFlakeId, err := GenerateSnowFlakeId() if err != nil { return "", err } return strconv.Itoa(int(snowFlakeId)), nil } ``` **使用说明:** ```go // 生成 int64 类型的 ID id, err := snowflake.GenerateSnowFlakeId() if err != nil { // 处理错误 } fmt.Println("ID:", id) // 输出:ID: 1234567890123456789 // 生成 string 类型的 ID idStr, err := snowflake.GenerateSnowFlakeIdString() if err != nil { // 处理错误 } fmt.Println("ID:", idStr) // 输出:ID: "1234567890123456789" ``` ##### 定义用户角色枚举 创建 `pkg/enum/user_role.go` 文件: ```go package enum // UserRoleEnum 用户角色枚举 type UserRoleEnum string const ( UserRole UserRoleEnum = "user" AdminRole UserRoleEnum = "admin" ) var roleTextMap = map[UserRoleEnum]string{ UserRole: "用户", AdminRole: "管理员", } // GetRoleText 获取角色文本 func (e UserRoleEnum) GetRoleText() string { if text, ok := roleTextMap[e]; ok { return text } return "未知角色" } ``` ##### 修改 User 模型的 ID tag **为什么需要修改 ID 的 JSON 类型?** 雪花 ID 是一个 64 位整数(int64),在 JavaScript 中可能会丢失精度: ```javascript // JavaScript 中的问题 const id = 1234567890123456789; console.log(id); // 输出:1234567890123456800(精度丢失) ``` **解决方案:将生成的User模型 ID 序列化为字符串** ![在这里插入图片描述](https://pic.code-nav.cn/post_picture/1969049470100254722/xRTp1w8vX95yniXm.webp) ##### 修改自动生成模型结构体脚本文件 由于 `user.gen.go` 是自动生成的文件,每次运行自动生成模型的脚本都会被覆盖,所以我们将要对脚本文件的配置做出修改: 修改 `cmd/gen/main.go`: ```go package main import ( "gorm.io/gen" "yikou-ai-go-teach/config" "yikou-ai-go-teach/internal/dal" ) func main() { initConfig := config.InitConfig() db := dal.InitDB(initConfig) g := gen.NewGenerator(gen.Config{ OutPath: "./internal/dal/query", ModelPkgPath: "model", Mode: gen.WithoutContext | gen.WithDefaultQuery | gen.WithQueryInterface, }) g.UseDB(db) // 为所有表统一配置 id 字段的tag处理 g.ApplyBasic(g.GenerateAllTable( gen.FieldJSONTag("id","id,string"), )...) g.Execute() } ``` #### 9. 正式开发接口 ##### 用户注册接口 用户注册是用户模块的核心功能之一,涉及参数校验、密码加密、用户创建等业务逻辑。 ###### Service 接口定义 在 `internal/service/user_service.go` 中定义用户注册相关接口: ```go package service import ( "context" "yikou-ai-go-teach/internal/api" ) // IUserService 用户服务接口 type IUserService interface { // UserRegister 用户注册 UserRegister(ctx context.Context, req *api.YiKouUserRegisterRequest) (int64, error) // GetEncryptPassword 获取加密后的密码 GetEncryptPassword(ctx context.Context, password string) string } ``` ###### **UserService 结构体定义:** 创建 `internal/logic/user_logic.go` 文件: ```go package logic import ( "context" "crypto/md5" "encoding/hex" "gorm.io/gorm" "yikou-ai-go-teach/internal/api" "yikou-ai-go-teach/internal/dal/model" "yikou-ai-go-teach/internal/dal/query" "yikou-ai-go-teach/pkg/enum" "yikou-ai-go-teach/pkg/errorutil" "yikou-ai-go-teach/pkg/snowflake" ) // UserService 用户服务实现 type UserService struct { db *gorm.DB // 依赖注入:数据库连接 } // NewUserService 创建用户服务(构造函数) func NewUserService(db *gorm.DB) *UserService { return &UserService{ db: db, } } ``` **密码加密方法:** ```go // GetEncryptPassword 获取加密后的密码 func (s *UserService) GetEncryptPassword(ctx context.Context, password string) string { h := md5.New() h.Write([]byte("feiwu" + password)) // 加盐(salt = "feiwu") return hex.EncodeToString(h.Sum(nil)) } ``` **用户注册方法:** ```go // UserRegister 用户注册 func (s *UserService) UserRegister(ctx context.Context, req *api.YiKouUserRegisterRequest) (int64, error) { // 1. 校验参数 if req.UserAccount == "" || req.UserPassword == "" || req.CheckPassword == "" { return 0, errorutil.ParamsError } if len(req.UserAccount) < 4 || len(req.UserAccount) > 12 { return 0, errorutil.ParamsError.WithMessage("用户账号长度必须在4到12之间") } if len(req.UserPassword) < 8 || len(req.UserPassword) > 12 { return 0, errorutil.ParamsError.WithMessage("用户密码长度必须在8到12之间") } if req.UserPassword != req.CheckPassword { return 0, errorutil.ParamsError.WithMessage("两次输入密码不一致") } // 2. 校验用户名是否已被注册 count, _ := query.Use(s.db).User.Where(query.User.UserAccount.Eq(req.UserAccount)).Count() if count > 0 { return 0, errorutil.ParamsError.WithMessage("用户名已被注册") } // 3. 密码加密 encryptPassword := s.GetEncryptPassword(ctx, req.UserPassword) // 4. 生成用户 ID(雪花 ID) userId, err := snowflake.GenerateSnowFlakeId() if err != nil { return 0, err } // 5. 创建用户 newUser := &model.User{ ID: userId, UserAccount: req.UserAccount, UserPassword: encryptPassword, UserName: "无名", // 默认用户名 UserRole: string(enum.UserRole), // 默认角色 } err = query.Use(s.db).User.Create(newUser) if err != nil { return 0, err } return newUser.ID, nil } ``` **注册流程图:** ``` 用户注册请求 ↓ 参数校验(账号、密码长度、确认密码) ↓ 查询账号是否已存在 ↓ 密码加密(MD5 + 盐) ↓ 生成雪花 ID ↓ 创建用户记录 ↓ 返回用户 ID ``` ###### Handler 接口逻辑 创建 `internal/handler/user_handler.go` 文件: ```go package handler import ( "context" "github.com/cloudwego/hertz/pkg/app" "github.com/cloudwego/hertz/pkg/protocol/consts" "yikou-ai-go-teach/internal/api" "yikou-ai-go-teach/internal/service" "yikou-ai-go-teach/pkg/response" ) // UserHandler 用户处理器 type UserHandler struct { userService service.IUserService // 依赖注入:用户服务接口 } // NewUserHandler 创建用户处理器(构造函数) func NewUserHandler(userService service.IUserService) *UserHandler { return &UserHandler{ userService: userService, } } ``` **用户注册接口:** ```go // UserRegister 用户注册 // @Summary 用户注册 // @Description 用户注册 // @Tags 用户模块 // @Accept json // @Produce json // @Param req body api.YiKouUserRegisterRequest true "用户注册请求" // @Success 200 {object} api.YiKouUserRegisterResponse "用户ID" // @Router /user/register [post] func (u *UserHandler) UserRegister(ctx context.Context, c *app.RequestContext) { // 1. 绑定和校验请求参数 req := &api.YiKouUserRegisterRequest{} err := c.BindAndValidate(req) if err != nil { c.JSON(consts.StatusOK, response.NewErrorResponse[any](err)) return } // 2. 调用 Service 层处理业务逻辑 userId, err := u.userService.UserRegister(ctx, req) if err != nil { c.JSON(consts.StatusOK, response.NewErrorResponse[any](err)) return } // 3. 返回成功响应 c.JSON(consts.StatusOK, response.NewSuccessResponse[int64](userId)) } ``` **请求和响应结构体:** 创建 `internal/api/user.go` 文件: ```go package api import ( "yikou-ai-go-teach/pkg/response" ) // YiKouUserRegisterRequest 用户注册请求 type YiKouUserRegisterRequest struct { UserAccount string `json:"userAccount"` // 用户账号 UserPassword string `json:"userPassword"` // 用户密码 CheckPassword string `json:"checkPassword"` // 确认密码 } // YiKouUserRegisterResponse 用户注册响应 type YiKouUserRegisterResponse response.BaseResponse[int64] ``` ##### 用户登录相关接口 用户登录模块包含用户登录、获取登录用户信息、退出登录等功能。 ###### Service 接口定义 在 `internal/service/user_service.go` 中添加用户登录相关接口: ```go package service import ( "context" "github.com/cloudwego/hertz/pkg/app" "yikou-ai-go-teach/internal/api" "yikou-ai-go-teach/internal/dal/vo" ) // IUserService 用户服务接口 type IUserService interface { // GetLoginUserVo 获取登录用户信息 GetLoginUserVo(ctx context.Context, c *app.RequestContext) (vo.UserVo, error) // UserLogin 用户登录 UserLogin(ctx context.Context, req *api.YiKouUserLoginRequest, c *app.RequestContext) (vo.UserVo, error) // Logout 退出登录 Logout(ctx context.Context, c *app.RequestContext) error } ``` ###### Logic 业务逻辑实现 **常量定义:** 创建 `pkg/constants/constants.go` 文件: ```go package constants // UserLoginState 用户登录状态 Cookie 名称 const UserLoginState = "user_login" ``` **用户登录方法:** ```go // UserLogin 用户登录 func (s *UserService) UserLogin(ctx context.Context, req *api.YiKouUserLoginRequest, c *app.RequestContext) (vo.UserVo, error) { // 1. 校验参数 if req.UserAccount == "" || req.UserPassword == "" { return vo.UserVo{}, errorutil.ParamsError } // 2. 查询用户是否存在 user, err := query.Use(s.db).User.Where(query.User.UserAccount.Eq(req.UserAccount)).First() if err != nil { return vo.UserVo{}, err } // 3. 校验密码是否正确 encryptPassword := s.GetEncryptPassword(ctx, req.UserPassword) if user.UserPassword != encryptPassword { return vo.UserVo{}, errorutil.ParamsError.WithMessage("密码错误") } // 4. 序列化用户信息 userJson, err := json.Marshal(user) if err != nil { return vo.UserVo{}, err } // 5. 保存用户信息到 Cookie(登录状态) c.SetCookie(constants.UserLoginState, string(userJson), 86400, "/", "", protocol.CookieSameSiteLaxMode, false, true) // 6. 构建脱敏的用户信息 loginUserVo := vo.UserVo{ ID: user.ID, UserAccount: user.UserAccount, UserName: user.UserName, UserAvatar: user.UserAvatar, UserProfile: user.UserProfile, UserRole: user.UserRole, CreateTime: user.CreateTime, UpdateTime: user.UpdateTime, } return loginUserVo, nil } ``` **登录流程图:** ``` 用户登录请求 ↓ 参数校验 ↓ 查询用户信息 ↓ 密码比对 ↓ 生成登录状态(Cookie) ↓ 返回脱敏用户信息 ``` **获取登录用户信息:** ```go // GetLoginUserVo 获取登录用户信息 func (s *UserService) GetLoginUserVo(ctx context.Context, c *app.RequestContext) (vo.UserVo, error) { // 1. 从 Cookie 中获取用户信息 userJson := c.Request.Header.Cookie(constants.UserLoginState) if userJson == nil || string(userJson) == "" { return vo.UserVo{}, errorutil.ParamsError.WithMessage("登录已过期,请重新登录") } // 2. URL 解码 decodeUser, err := url.QueryUnescape(string(userJson)) if err != nil { return vo.UserVo{}, err } // 3. 反序列化用户信息 var user model.User err = json.Unmarshal([]byte(decodeUser), &user) if err != nil { return vo.UserVo{}, err } // 4. 校验用户是否仍然存在 _, err = query.Use(s.db).User.Where(query.User.ID.Eq(user.ID), query.User.IsDelete.Eq(0)).First() if err != nil { return vo.UserVo{}, err } // 5. 构建返回对象 loginUserVo := vo.UserVo{ ID: user.ID, UserAccount: user.UserAccount, UserName: user.UserName, UserAvatar: user.UserAvatar, UserProfile: user.UserProfile, UserRole: user.UserRole, CreateTime: user.CreateTime, UpdateTime: user.UpdateTime, } return loginUserVo, nil } ``` **退出登录:** ```go // Logout 退出登录 func (s *UserService) Logout(ctx context.Context, c *app.RequestContext) error { // 清除 Cookie c.SetCookie(constants.UserLoginState, "", 0, "/", "", protocol.CookieSameSiteLaxMode, false, true) return nil } ``` ###### Handler 接口逻辑 **用户登录接口:** ```go // UserLogin 用户登录 // @Summary 用户登录 // @Description 用户登录 // @Tags 用户模块 // @Accept json // @Produce json // @Param req body api.YiKouUserLoginRequest true "用户登录请求" // @Success 200 {object} api.YiKouUserLoginResponse "登录用户信息" // @Router /user/login [post] func (u *UserHandler) UserLogin(ctx context.Context, c *app.RequestContext) { // 1. 绑定和校验请求参数 req := &api.YiKouUserLoginRequest{} err := c.BindAndValidate(req) if err != nil { c.JSON(consts.StatusOK, response.NewErrorResponse[any](err)) return } // 2. 调用 Service 层处理登录逻辑 userVo, err := u.userService.UserLogin(ctx, req, c) if err != nil { c.JSON(consts.StatusOK, response.NewErrorResponse[any](err)) return } // 3. 返回用户信息(脱敏) c.JSON(consts.StatusOK, response.NewSuccessResponse[vo.UserVo](userVo)) } ``` **获取登录用户信息接口:** ```go // GetLoginUser 获取登录用户信息 // @Summary 获取登录用户信息 // @Description 获取登录用户信息 // @Tags 用户模块 // @Accept json // @Produce json // @Success 200 {object} api.YiKouUserLoginResponse "登录用户信息" // @Router /user/get/login [get] func (u *UserHandler) GetLoginUser(ctx context.Context, c *app.RequestContext) { // 直接从 Session 或 Token 中获取登录用户信息 userVo, err := u.userService.GetLoginUserVo(ctx, c) if err != nil { c.JSON(consts.StatusOK, response.NewErrorResponse[any](err)) return } c.JSON(consts.StatusOK, response.NewSuccessResponse[vo.UserVo](userVo)) } ``` **退出登录接口:** ```go // Logout 退出登录 // @Summary 退出登录 // @Description 退出登录 // @Tags 用户模块 // @Accept json // @Produce json // @Success 200 {object} bool "退出登录成功" // @Router /user/logout [get] func (u *UserHandler) Logout(ctx context.Context, c *app.RequestContext) { // 清除 Session 或 Token err := u.userService.Logout(ctx, c) if err != nil { c.JSON(consts.StatusOK, response.NewErrorResponse[any](err)) return } c.JSON(consts.StatusOK, response.NewSuccessResponse[any](true)) } ``` **请求和响应结构体:** ```go // YiKouUserLoginRequest 用户登录请求 type YiKouUserLoginRequest struct { UserAccount string `json:"userAccount"` // 用户账号 UserPassword string `json:"userPassword"` // 用户密码 } // YiKouUserLoginResponse 用户登录响应 type YiKouUserLoginResponse response.BaseResponse[vo.UserVo] ``` ##### 用户管理相关接口 用户管理模块包含新增用户、获取用户、删除用户、更新用户、分页查询等CRUD操作。 ###### Service 接口定义 在 `internal/service/user_service.go` 中添加用户管理相关接口: ```go package service import ( "context" "yikou-ai-go-teach/internal/api" "yikou-ai-go-teach/internal/dal/model" "yikou-ai-go-teach/internal/dal/vo" "yikou-ai-go-teach/pkg/response" ) // IUserService 用户服务接口 type IUserService interface { // AddUser 新增用户 AddUser(ctx context.Context, req *api.YiKouUserAddRequest) (int64, error) // GetUser 根据 ID 获取用户 GetUser(ctx context.Context, id int64) (*model.User, error) // GetUserVo 根据 ID 获取用户 VO(脱敏) GetUserVo(ctx context.Context, id int64) (vo.UserVo, error) // DeleteUser 删除用户 DeleteUser(ctx context.Context, id int64) (bool, error) // UpdateUser 更新用户 UpdateUser(ctx context.Context, req *api.YiKouUserUpdateRequest) (bool, error) // ListUserVoByPage 分页查询用户列表 ListUserVoByPage(ctx context.Context, req *api.YiKouUserQueryRequest) (*response.PageResponse[vo.UserVo], error) } ``` **接口方法说明:** | 方法名 | 参数 | 返回值 | 功能说明 | | ---------------- | ------------ | -------- | ------------------------ | | AddUser | 新增用户请求 | 用户ID | 新增用户(管理员功能) | | GetUser | 用户ID | 用户实体 | 根据ID获取用户(含密码) | | GetUserVo | 用户ID | 用户VO | 根据ID获取用户(脱敏) | | DeleteUser | 用户ID | 是否成功 | 删除用户(逻辑删除) | | UpdateUser | 更新请求 | 是否成功 | 更新用户信息 | | ListUserVoByPage | 分页请求 | 分页结果 | 分页查询用户列表 | ###### Logic 业务逻辑实现 **新增用户方法:** ```go // AddUser 新增用户 func (s *UserService) AddUser(ctx context.Context, req *api.YiKouUserAddRequest) (int64, error) { // 1. 校验参数 if req.UserAccount == "" || req.UserPassword == "" { return 0, errorutil.ParamsError } // 2. 校验用户名是否已被注册 count, _ := query.Use(s.db).User.Where(query.User.UserAccount.Eq(req.UserAccount)).Count() if count > 0 { return 0, errorutil.ParamsError.WithMessage("用户名已被注册") } // 3. 密码加密 encryptPassword := s.GetEncryptPassword(ctx, req.UserPassword) // 4. 生成用户 ID userId, err := snowflake.GenerateSnowFlakeId() if err != nil { return 0, err } // 5. 创建用户 newUser := &model.User{ ID: userId, UserAccount: req.UserAccount, UserPassword: encryptPassword, UserName: req.UserAccount, // 默认使用账号作为昵称 UserAvatar: req.UserAvatar, UserProfile: req.UserProfile, UserRole: req.UserRole, } err = query.Use(s.db).User.Create(newUser) if err != nil { return 0, err } return newUser.ID, nil } ``` **获取用户方法:** ```go // GetUser 根据 ID 获取用户 func (s *UserService) GetUser(ctx context.Context, id int64) (*model.User, error) { user, err := query.Use(s.db).User.Where(query.User.ID.Eq(id), query.User.IsDelete.Eq(0)).First() if err != nil { return nil, err } return user, nil } // GetUserVo 根据 ID 获取用户 VO(脱敏) func (s *UserService) GetUserVo(ctx context.Context, id int64) (vo.UserVo, error) { user, err := s.GetUser(ctx, id) if err != nil { return vo.UserVo{}, err } return vo.UserVo{ ID: user.ID, UserAccount: user.UserAccount, UserName: user.UserName, UserAvatar: user.UserAvatar, UserProfile: user.UserProfile, UserRole: user.UserRole, CreateTime: user.CreateTime, UpdateTime: user.UpdateTime, }, nil } ``` **删除用户方法:** ```go // DeleteUser 删除用户 func (s *UserService) DeleteUser(ctx context.Context, id int64) (bool, error) { // 逻辑删除:设置 is_delete = 1 _, err := query.Use(s.db).User.Where(query.User.ID.Eq(id)). Update(query.User.IsDelete, 1) if err != nil { return false, err } return true, nil } ``` **更新用户方法:** ```go // UpdateUser 更新用户 func (s *UserService) UpdateUser(ctx context.Context, req *api.YiKouUserUpdateRequest) (bool, error) { // 1. 校验用户是否存在 _, err := query.Use(s.db).User.Where(query.User.ID.Eq(int64(req.Id)), query.User.IsDelete.Eq(0)).First() if err != nil { return false, errorutil.ParamsError.WithMessage("用户不存在") } // 2. 更新用户信息 _, err = query.Use(s.db).User.Where(query.User.ID.Eq(int64(req.Id))). Updates(map[string]interface{}{ "user_name": req.UserName, "user_avatar": req.UserAvatar, "user_profile": req.UserProfile, "user_role": req.UserRole, }) if err != nil { return false, err } return true, nil } ``` **分页查询用户列表方法:** ```go // ListUserVoByPage 分页查询用户列表 func (s *UserService) ListUserVoByPage(ctx context.Context, req *api.YiKouUserQueryRequest) (*response.PageResponse[vo.UserVo], error) { // 1. 构建查询条件 q := query.Use(s.db).User.Where(query.User.IsDelete.Eq(0)) if req.UserAccount != "" { q = q.Where(query.User.UserAccount.Like("%" + req.UserAccount + "%")) } if req.UserName != "" { q = q.Where(query.User.UserName.Like("%" + req.UserName + "%")) } if req.UserRole != "" { q = q.Where(query.User.UserRole.Eq(req.UserRole)) } // 2. 查询总数 total, err := q.Count() if err != nil { return nil, err } // 3. 分页查询 pageNum := req.PageNum pageSize := req.PageSize if pageNum <= 0 { pageNum = 1 } if pageSize <= 0 { pageSize = 10 } users, err := q.Offset((pageNum - 1) * pageSize).Limit(pageSize).Find() if err != nil { return nil, err } // 4. 转换为 VO userVos := make([]vo.UserVo, 0, len(users)) for _, user := range users { userVos = append(userVos, vo.UserVo{ ID: user.ID, UserAccount: user.UserAccount, UserName: user.UserName, UserAvatar: user.UserAvatar, UserProfile: user.UserProfile, UserRole: user.UserRole, CreateTime: user.CreateTime, UpdateTime: user.UpdateTime, }) } // 5. 计算总页数 totalPage := int(total) / pageSize if int(total)%pageSize != 0 { totalPage++ } return &response.PageResponse[vo.UserVo]{ Records: userVos, PageNum: pageNum, PageSize: pageSize, TotalRow: int(total), TotalPage: totalPage, }, nil } ``` ###### Handler 接口逻辑 **UserHandler 结构体定义:** ```go package handler import ( "context" "strconv" "github.com/cloudwego/hertz/pkg/app" "github.com/cloudwego/hertz/pkg/protocol/consts" "yikou-ai-go-teach/internal/api" "yikou-ai-go-teach/internal/dal/model" "yikou-ai-go-teach/internal/dal/vo" "yikou-ai-go-teach/internal/service" "yikou-ai-go-teach/pkg/errorutil" "yikou-ai-go-teach/pkg/request" "yikou-ai-go-teach/pkg/response" ) // UserHandler 用户处理器 type UserHandler struct { userService service.IUserService } // NewUserHandler 创建用户处理器 func NewUserHandler(userService service.IUserService) *UserHandler { return &UserHandler{ userService: userService, } } ``` **新增用户接口:** ```go // AddUser 新增用户 // @Summary 新增用户 // @Description 新增用户 // @Tags 用户模块 // @Accept json // @Produce json // @Param req body api.YiKouUserAddRequest true "新增用户请求" // @Success 200 {object} api.YiKouUserAddResponse "用户ID" // @Router /user/add [post] func (u *UserHandler) AddUser(ctx context.Context, c *app.RequestContext) { req := &api.YiKouUserAddRequest{} err := c.BindAndValidate(req) if err != nil { c.JSON(consts.StatusOK, response.NewErrorResponse[any](err)) return } userId, err := u.userService.AddUser(ctx, req) if err != nil { c.JSON(consts.StatusOK, response.NewErrorResponse[any](err)) return } c.JSON(consts.StatusOK, response.NewSuccessResponse[int64](userId)) } ``` **获取用户接口:** ```go // GetUser 根据ID获取用户 // @Summary 根据ID获取用户 // @Description 根据ID获取用户 // @Tags 用户模块 // @Accept json // @Produce json // @Param id query int true "用户ID" // @Success 200 {object} api.YiKouUserGetResponse "用户信息" // @Router /user/get [get] func (u *UserHandler) GetUser(ctx context.Context, c *app.RequestContext) { id := c.Query("id") if id == "" { c.JSON(consts.StatusOK, response.NewErrorResponse[any](errorutil.ParamsError)) return } idInt64, _ := strconv.ParseInt(id, 10, 64) user, err := u.userService.GetUser(ctx, idInt64) if err != nil { c.JSON(consts.StatusOK, response.NewErrorResponse[any](err)) return } c.JSON(consts.StatusOK, response.NewSuccessResponse[model.User](*user)) } ``` **删除用户接口:** ```go // DeleteUser 删除用户 // @Summary 删除用户 // @Description 删除用户 // @Tags 用户模块 // @Accept json // @Produce json // @Param req body request.DeleteRequest true "删除用户请求" // @Success 200 {object} api.YiKouUserDeleteResponse "删除结果" // @Router /user/delete [post] func (u *UserHandler) DeleteUser(ctx context.Context, c *app.RequestContext) { req := &request.DeleteRequest{} err := c.BindAndValidate(req) if err != nil { c.JSON(consts.StatusOK, response.NewErrorResponse[any](err)) return } success, err := u.userService.DeleteUser(ctx, int64(req.Id)) if err != nil { c.JSON(consts.StatusOK, response.NewErrorResponse[any](err)) return } c.JSON(consts.StatusOK, response.NewSuccessResponse[bool](success)) } ``` **更新用户接口:** ```go // UpdateUser 更新用户 // @Summary 更新用户 // @Description 更新用户 // @Tags 用户模块 // @Accept json // @Produce json // @Param req body api.YiKouUserUpdateRequest true "更新用户请求" // @Success 200 {object} api.YiKouUserUpdateResponse "更新结果" // @Router /user/update [post] func (u *UserHandler) UpdateUser(ctx context.Context, c *app.RequestContext) { req := &api.YiKouUserUpdateRequest{} err := c.BindAndValidate(req) if err != nil { c.JSON(consts.StatusOK, response.NewErrorResponse[any](err)) return } if req.Id == 0 { c.JSON(consts.StatusOK, response.NewErrorResponse[any](errorutil.ParamsError.WithMessage("用户ID不能为空"))) return } success, err := u.userService.UpdateUser(ctx, req) if err != nil { c.JSON(consts.StatusOK, response.NewErrorResponse[any](err)) return } c.JSON(consts.StatusOK, response.NewSuccessResponse[bool](success)) } ``` **分页查询接口:** ```go // ListUserVoByPage 分页获取用户VO列表 // @Summary 分页获取用户VO列表 // @Description 分页获取用户VO列表 // @Tags 用户模块 // @Accept json // @Produce json // @Param req body api.YiKouUserQueryRequest true "分页查询请求" // @Success 200 {object} api.YiKouUserPageVoResponse "分页用户VO列表" // @Router /user/list/page/vo [post] func (u *UserHandler) ListUserVoByPage(ctx context.Context, c *app.RequestContext) { req := &api.YiKouUserQueryRequest{} err := c.BindAndValidate(req) if err != nil { c.JSON(consts.StatusOK, response.NewErrorResponse[any](err)) return } pageResponse, err := u.userService.ListUserVoByPage(ctx, req) if err != nil { c.JSON(consts.StatusOK, response.NewErrorResponse[any](err)) return } c.JSON(consts.StatusOK, response.NewSuccessResponse[*response.PageResponse[vo.UserVo]](pageResponse)) } ``` **请求和响应结构体:** ```go // YiKouUserAddRequest 新增用户请求 type YiKouUserAddRequest struct { UserAccount string `json:"userAccount"` // 用户账号 UserPassword string `json:"userPassword"` // 用户密码 UserAvatar string `json:"userAvatar"` // 用户头像 UserProfile string `json:"userProfile"` // 用户简介 UserRole string `json:"userRole"` // 用户角色 } // YiKouUserUpdateRequest 更新用户请求 type YiKouUserUpdateRequest struct { request.DeleteRequest // 嵌入删除请求(包含 Id) UserName string `json:"userName"` // 用户昵称 UserAvatar string `json:"userAvatar"` // 用户头像 UserProfile string `json:"userProfile"` // 用户简介 UserRole string `json:"userRole"` // 用户角色 } // YiKouUserQueryRequest 查询用户请求 type YiKouUserQueryRequest struct { request.PageRequest // 嵌入分页请求 UserAccount string `json:"userAccount"` // 用户账号(模糊查询) UserProfile string `json:"userProfile"` // 用户简介(模糊查询) UserName string `json:"userName"` // 用户昵称(模糊查询) UserRole string `json:"userRole"` // 用户角色(精确查询) } ``` #### 10. 修改路由配置 路由配置是将 Handler 层的接口方法映射到 HTTP 路由的关键步骤。本节将详细介绍如何配置路由、注册中间件以及组织路由分组。 **路由分组的好处:** | 好处 | 说明 | | ---------- | ---------------------------- | | 统一前缀 | 所有路由自动添加相同前缀 | | 中间件隔离 | 可以为不同分组应用不同中间件 | | 权限控制 | 便于按权限组织路由 | | 代码清晰 | 路由结构一目了然 | **路由分组示例:** ```go // 用户路由分组 userRoute := h.Group("/user") { // 公开接口 userRoute.POST("/register", userHandler.UserRegister) userRoute.POST("/login", userHandler.UserLogin) // 需要登录的接口 userRoute.GET("/get/login", userHandler.GetLoginUser) userRoute.POST("/logout", userHandler.Logout) // 需要管理员权限的接口 userRoute.POST("/add", userHandler.AddUser) userRoute.POST("/delete", userHandler.DeleteUser) } ``` ##### 增加鉴权中间件 鉴权中间件用于验证用户身份和权限,确保只有具备相应权限的用户才能访问特定的接口。 **创建鉴权中间件文件:** 创建 `internal/middleware/auth.go` 文件: ```go package middleware import ( "context" "encoding/json" "net/url" "github.com/cloudwego/hertz/pkg/app" "gorm.io/gorm" "yikou-ai-go-teach/internal/dal/model" "yikou-ai-go-teach/internal/dal/query" "yikou-ai-go-teach/pkg/constants" "yikou-ai-go-teach/pkg/enum" "yikou-ai-go-teach/pkg/errorutil" ) // AuthMiddleware 鉴权中间件 func AuthMiddleware(roleEnum enum.UserRoleEnum, db *gorm.DB) app.HandlerFunc { return func(ctx context.Context, c *app.RequestContext) { // 1. 校验权限 var decodeUser []byte if roleEnum != "" { // 2. 获取存储在 Cookie 的用户信息 userJson := c.Request.Header.Cookie(constants.UserLoginState) if userJson == nil { c.JSON(200, errorutil.NotLoginError) c.Abort() return } // 3. URL 解码 decodeUserJson, err := url.QueryUnescape(string(userJson)) if err != nil { c.JSON(200, errorutil.NotLoginError) c.Abort() return } decodeUser = []byte(decodeUserJson) } // 4. 解析用户信息 var user model.User err := json.Unmarshal(decodeUser, &user) if err != nil { c.JSON(200, errorutil.SystemError.WithMessage(err.Error())) c.Abort() return } // 5. 校验用户权限等级是否符合要求 dbUser, err := query.Use(db).User.Where(query.User.ID.Eq(user.ID), query.User.IsDelete.Eq(0)).First() if err != nil { c.JSON(200, errorutil.NotAuthError) c.Abort() return } // 6. 如果要求管理员权限,校验用户角色 if roleEnum == enum.AdminRole && enum.UserRoleEnum(dbUser.UserRole) != roleEnum { c.JSON(200, errorutil.NotAuthError) c.Abort() return } // 7. 权限校验通过,继续执行 c.Next(ctx) } } ``` **鉴权流程图:** ``` 请求到达 ↓ 检查是否需要鉴权 ↓ 获取 Cookie 中的用户信息 ↓ URL 解码用户信息 ↓ 反序列化用户对象 ↓ 查询数据库验证用户存在 ↓ 校验用户角色权限 ↓ 权限通过 → 继续执行 Handler 权限失败 → 返回错误响应 ``` **在路由中使用鉴权中间件:** ```go // 用户路由分组 userRoute := h.Group("/user") { // 公开接口(无需鉴权) userRoute.POST("/register", userHandler.UserRegister) userRoute.POST("/login", userHandler.UserLogin) userRoute.GET("/get/vo", userHandler.GetUserVo) // 需要登录的接口(用户权限) userRoute.GET("/get/login", middleware.AuthMiddleware(enum.UserRole, db), userHandler.GetLoginUser, ) userRoute.POST("/logout", middleware.AuthMiddleware(enum.UserRole, db), userHandler.Logout, ) // 需要管理员权限的接口 userRoute.POST("/add", middleware.AuthMiddleware(enum.AdminRole, db), userHandler.AddUser, ) userRoute.GET("/get", middleware.AuthMiddleware(enum.AdminRole, db), userHandler.GetUser, ) userRoute.POST("/delete", middleware.AuthMiddleware(enum.AdminRole, db), userHandler.DeleteUser, ) userRoute.POST("/update", middleware.AuthMiddleware(enum.AdminRole, db), userHandler.UpdateUser, ) userRoute.POST("/list/page/vo", middleware.AuthMiddleware(enum.AdminRole, db), userHandler.ListUserVoByPage, ) } ``` **关键点说明:** 1. **中间件链**:可以串联多个中间件,按顺序执行 2. **权限传递**:通过 `c.Next(ctx)` 继续执行后续中间件或 Handler 3. **请求中止**:通过 `c.Abort()` 中止请求,不再执行后续处理 4. **上下文传递**:中间件可以在上下文中存储数据,供后续使用 #### 11. 修改依赖注入文件 修改后的 `wire/wire.go` 文件: ```go //go:build wireinject package wire import ( "fmt" "strconv" "github.com/cloudwego/hertz/pkg/app/server" "github.com/google/wire" "github.com/hertz-contrib/swagger" "yikou-ai-go-teach/config" "yikou-ai-go-teach/docs" "yikou-ai-go-teach/internal/dal" "yikou-ai-go-teach/internal/handler" "yikou-ai-go-teach/internal/logic" "yikou-ai-go-teach/internal/router" "yikou-ai-go-teach/internal/service" ) // 配置依赖 var configSet = wire.NewSet( config.InitConfig, ) // 数据库依赖 var dbSet = wire.NewSet( dal.InitDB, ) // Service 依赖 var serviceSet = wire.NewSet( logic.NewUserService, wire.Bind(new(service.IUserService), new(*logic.UserService)), ) // Handler 依赖 var handlerSet = wire.NewSet( handler.NewUserHandler, ) // initServer 初始化 Web 服务器 func initServer(cfg *config.Config, userHandler *handler.UserHandler) *server.Hertz { // 动态设置 Swagger 信息 docs.SwaggerInfo.Host = fmt.Sprintf("localhost:%d", cfg.Server.Port) docs.SwaggerInfo.BasePath = cfg.Server.ContextPath // 初始化 Swagger 路径 swaggerPath := fmt.Sprintf("http://localhost:%d%s/swagger/doc.json", cfg.Server.Port, cfg.Server.ContextPath) url := swagger.URL(swaggerPath) // 创建 Hertz 服务器 h := server.Default( server.WithHostPorts(":"+strconv.Itoa(cfg.Server.Port)), server.WithBasePath(cfg.Server.ContextPath), ) // 注册路由 router.RegisterRoutes(h, url, userHandler) return h } // InitializeApp 初始化所有依赖(依赖图) func InitializeApp() (*server.Hertz, error) { panic(wire.Build( initServer, configSet, dbSet, serviceSet, handlerSet, )) } ``` ##### 增加依赖集合 **数据库依赖集合:** ```go var dbSet = wire.NewSet( dal.InitDB, // 初始化数据库连接 ) ``` **Service 依赖集合:** ```go var serviceSet = wire.NewSet( logic.NewUserService, // 创建 UserService 实例 wire.Bind(new(service.IUserService), new(*logic.UserService)), // 接口绑定 ) ``` **这里也许会有小伙伴问道:为什么需要接口绑定?** ```go // Service 接口定义 type IUserService interface { UserRegister(ctx context.Context, req *api.YiKouUserRegisterRequest) (int64, error) } // Service 实现 type UserService struct { db *gorm.DB } // Handler 依赖接口 type UserHandler struct { userService service.IUserService // 依赖接口,而非实现 } ``` **Wire 接口绑定:** ```go wire.Bind(new(service.IUserService), new(*logic.UserService)) ``` 这行代码告诉 Wire: - `*logic.UserService` 实现了 `service.IUserService` 接口 - 当需要 `IUserService` 时,提供 `*UserService` 实例 **Handler 依赖集合:** ```go var handlerSet = wire.NewSet( handler.NewUserHandler, // 创建 UserHandler 实例 ) ``` **修改Server的Provider方法:** ```go // initServer 初始化 Web 服务器 func initServer(cfg *config.Config, userHandler *handler.UserHandler, db *gorm.DB) *server.Hertz { // 动态设置 Swagger 信息 docs.SwaggerInfo.Host = fmt.Sprintf("localhost:%d", cfg.Server.Port) docs.SwaggerInfo.BasePath = cfg.Server.ContextPath // 初始化swagger路径 swaggerPath := fmt.Sprintf("http://localhost:%d%s/swagger/doc.json", cfg.Server.Port, cfg.Server.ContextPath) url := swagger.URL(swaggerPath) // 创建 Hertz 服务器 h := server.Default( server.WithHostPorts(":"+strconv.Itoa(cfg.Server.Port)), server.WithBasePath(cfg.Server.ContextPath), ) // 注册路由 router.RegisterRoutes(h, url, db, userHandler) return h } ``` **修改依赖图:** ```go // InitializeApp 初始化所有依赖(依赖图) func InitializeApp() (*server.Hertz, error) { panic(wire.Build( initServer, configSet, dbSet, serviceSet, handlerSet, )) } ``` ##### 生成依赖注入代码 **重新生成代码:** ```bash cd wire wire ``` ### 四、测试接口 到这里为止,我们的所有准备工作都基本上做好了,接下来我们执行swagger的生成命令 `swag init`生成接口文档就可以直接启动服务器。然后访问swagger文档一个一个接口的进行测试,接口测试的具体步骤我就不放给各位小伙伴们了。大家可以锻炼下自己的接口测试能力,按照上面的教程步骤测试接口。 ![在这里插入图片描述](https://pic.code-nav.cn/post_picture/1969049470100254722/hsAKPuBLiMcyJ1gL.webp) **第二章的内容到此就为止了,在下一章,我会讲解整个项目最关键的部分:AI应用生成模块,相对于前面的教程,下一章的内容绝对包含了目前Go社区中最前沿且最有深度的干货,若想接着关注我的教程实况,请为我的仓库 [https://github.com/FeiWuSama/yikou-ai-go](https://github.com/FeiWuSama/yikou-ai-go) 点个Star ⭐**

下载 APP